Verify CDN integrity with SHA-384 and SRI
To pin a CDN script to bytes you trust, generate a SHA-384 digest from your approved release file
and put it in the script’s integrity attribute. This walkthrough gives you a hash command that
fails on missing input and a local browser demo that proves changed scripts cannot execute.
Although sha384sum calculates the right algorithm, its hexadecimal output needs a different
encoding for Subresource Integrity (SRI).
Understand subresource integrity (SRI)
The browser downloads the script, hashes its contents, and compares the result with the expected value in your HTML before executing it. A mismatch blocks execution. The hash must come from a trusted build or independently verified release: hashing a compromised CDN response and accepting that value would approve the replacement. Your HTML is part of this trust boundary too; someone who can change both the script and its expected hash can defeat the check.
SRI supports SHA-256, SHA-384, and SHA-512. SHA-384 is a useful baseline with a shorter digest than SHA-512. If you supply multiple algorithms, browsers use the strongest supported one, without falling back to a weaker one after a mismatch. See the SRI specification.
Generate a hash on the command line
Use Bash and OpenSSL for the command, plus Node.js for the local servers below. This example was tested on Linux with Bash 5.3.15, OpenSSL 3.6.4, Node.js 26.8.1, and Chromium 152. It needs no package installation or CDN account. Save the example files in a new, empty directory.
Save this as demo.js. It stands in for the approved release file you would normally obtain from
your build or publisher:
document.getElementById('status').textContent = 'Trusted script executed'
Save the following as sri.sh. It prints one SRI value on success, writes diagnostics to stderr on
failure, and never modifies the input file:
#!/usr/bin/env bash
set -o pipefail
if [[ $# -ne 1 || ! -f "$1" || ! -r "$1" ]]; then
printf 'Usage: bash sri.sh readable-file\n' >&2
exit 1
fi
if digest=$(openssl dgst -sha384 -binary < "$1" | openssl base64 -A); then
printf 'sha384-%s\n' "$digest"
else
printf 'Could not generate SRI for %s\n' "$1" >&2
exit 1
fi
OpenSSL’s -binary option produces the digest
bytes; base64 -A encodes them without line breaks.
Do not base64-encode the text printed by sha384sum: that text represents the digest in hex.
The status check matters as much as the encoding. A pipeline such as cat missing.js | openssl …
can hash an empty stream after cat fails. Here, Bash’s pipefail and the checked assignment keep
failed reads or failed OpenSSL commands from printing a usable value. Input redirection also keeps
filenames beginning with a hyphen from being interpreted as OpenSSL options. A readable empty file
is valid input and has its own digest; a missing file is an error.
Run the script directly with Bash:
bash sri.sh ./demo.js
The output starts with sha384-, followed by 64 base64 characters. Whitespace and line endings in
demo.js affect the result, so hash the exact bytes you will serve.
Embed SRI in your HTML or JSX
For a cross-origin classic script, set both integrity and crossorigin="anonymous". The asset
server must also send an appropriate Access-Control-Allow-Origin header. Either missing condition
can block the script even when its bytes match. MDN explains the CORS requirement.
In JSX, the attribute is spelled crossOrigin.
Save this as server.ts. It serves a page and its script on two different loopback ports, so the
browser treats them as different origins. The operating system chooses available ports. The hash
comes from the shell, while the changed response deliberately keeps the original expected hash.
import type { Server } from 'node:http'
import { once } from 'node:events'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function listen(server: Server, port = 0): Promise<string> {
server.listen(port, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address')
return `http://127.0.0.1:${address.port}`
}
async function main(): Promise<void> {
const integrity = process.env.SRI
if (!integrity || !/^sha384-[A-Za-z0-9+/]{64}$/.test(integrity)) {
throw new Error('Set SRI to the trusted value from sri.sh')
}
const pagePort = Number(process.env.PAGE_PORT ?? 0)
if (!Number.isInteger(pagePort) || pagePort < 0 || pagePort > 65535) {
throw new Error('PAGE_PORT must be an integer from 0 to 65535')
}
const trusted = await readFile('./demo.js')
const changed = Buffer.concat([trusted, Buffer.from('\n// Changed after release\n')])
const assets = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store')
if (!['/demo.js', '/changed.js', '/no-cors.js'].includes(request.url ?? '')) {
response.writeHead(404).end()
return
}
if (request.url !== '/no-cors.js') response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Content-Type', 'text/javascript; charset=utf-8')
response.end(request.url === '/changed.js' ? changed : trusted)
})
const assetOrigin = await listen(assets)
const pages = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store')
if (request.method === 'POST' && request.url === '/api/security-alerts') {
request.resume()
console.log('Local SRI alert received')
response.writeHead(204).end()
return
}
const file = request.url === '/changed' ? 'changed.js' : request.url === '/no-cors' ? 'no-cors.js' : 'demo.js'
const cors = request.url === '/no-attribute' ? '' : 'crossorigin="anonymous"'
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(`<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SRI demo</title>
<h1>SRI demo</h1>
<nav aria-label="Test cases">
<a href="/">Trusted</a> | <a href="/changed">Changed bytes</a> |
<a href="/no-cors">Missing CORS header</a> | <a href="/no-attribute">Missing crossorigin</a>
</nav>
<p id="status" role="status">Waiting for script</p>
<script src="${assetOrigin}/${file}" integrity="${integrity}" ${cors}></script>
</html>`)
})
const pageOrigin = await listen(pages, pagePort)
console.log(`Open ${pageOrigin}`)
}
main().catch((error: unknown) => {
console.error('SRI demo failed:', error instanceof Error ? error.message : 'Unknown error')
process.exit(1)
})
Start it from the directory containing all three files. The && prevents startup if hashing
fails; none of these commands writes an output file or overwrites your examples:
expected_sri=$(bash sri.sh ./demo.js) &&
SRI="$expected_sri" node server.ts
Open the printed URL in your browser. The Trusted page shows Trusted script executed. Each of the other links leaves Waiting for script on screen:
- Changed bytes serves an added comment. Even that harmless change causes a digest mismatch and prevents the entire script from executing.
- Missing CORS header serves the original bytes without the asset server’s permission to read them cross-origin.
- Missing crossorigin omits the element’s CORS attribute, while the asset server still sends its permission header.
Open the browser console to distinguish an integrity mismatch from a CORS error. A successful HTTP
response alone does not establish successful script execution. Stop both servers with Ctrl+C. The
demo reads demo.js once at startup and stores nothing; its alert endpoint is only a local receiver
for the optional monitoring example below. Use HTTPS for your real page and CDN. SRI does not
protect HTML delivered through a connection an attacker can rewrite.
Generate hashes in the browser with the Web Crypto API
For diagnostics, paste this function into the demo page’s developer console. It hashes the fetched bytes and rejects HTTP errors before reading an error page as a script:
async function generateSRIHash(url) {
const response = await fetch(url, {
cache: 'no-store',
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw new Error(`Unable to fetch resource: HTTP ${response.status}`)
const buffer = await response.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-384', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const binaryString = String.fromCharCode.apply(null, hashArray)
return `sha384-${btoa(binaryString)}`
}
On the Trusted page, run:
const resource = document.querySelector('script[integrity]')
console.log(await generateSRIHash(resource.src) === resource.integrity)
This prints true. The same comparison on the
Changed bytes page prints false. Keep the expected hash from
the trusted release; do not replace it with whatever this function fetches. This separate fetch
cannot prove which bytes an earlier script request executed. The element’s integrity attribute
performs that enforcement.
crypto.subtle.digest()
needs a secure context, such as HTTPS or this loopback demo. It buffers the whole resource in memory.
The optional JavaScript helpers also require
AbortSignal.timeout(),
which bounds each fetch, including reading its body, to ten seconds of active time. That timeout
can pause when a document is suspended. Check these APIs separately from SRI support when targeting
older browsers; browsers without SRI support do not enforce the attribute.
Load scripts dynamically
For a script added after page load, set the integrity and CORS properties before inserting it. Paste this helper into the same console, or include it in your application’s JavaScript:
async function loadScript(src, integrity) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
Object.assign(script, { src, integrity, crossOrigin: 'anonymous' })
script.addEventListener('load', () => resolve())
script.addEventListener('error', () => reject(new Error(`Failed to load or verify ${src}`)))
document.head.append(script)
})
}
Implement fallbacks
Use a byte-identical trusted mirror and keep the same expected hash on the fallback. This attempts the backup once and propagates its failure:
async function loadWithFallback(primary, backup, integrity) {
try {
await loadScript(primary, integrity)
} catch {
console.warn(`Primary failed, switching to ${backup}`)
await loadScript(backup, integrity)
}
}
With both helpers defined and resource from the comparison above, this makes the failed primary
request followed by a verified load from the demo’s original asset:
await loadWithFallback(
new URL('/changed.js', resource.src).href,
new URL('/demo.js', resource.src).href,
resource.integrity,
)
Monitor CDN assets in production
Periodic checks can report changes relative to a trusted hash, but browser tabs can close or
suspend. Use an independent scheduled job for operational monitoring. For a browser diagnostic,
this helper preserves one poll at a time, isolates failures per resource, checks alert HTTP status,
and allows polling to stop and restart. stop() prevents future polls; an active poll finishes.
class SRIMonitor {
#entries = new Map()
#intervalId
#checking = false
constructor(interval = 5 * 60_000) {
this.interval = interval
}
add(url, expectedHash) {
this.#entries.set(url, expectedHash)
}
async #check(url, expected) {
const actual = await generateSRIHash(url)
if (actual !== expected) {
console.warn(`[SRI] Mismatch for ${url}`)
const response = await fetch('/api/security-alerts', {
method: 'POST',
signal: AbortSignal.timeout(10_000),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, expected, actual }),
})
if (!response.ok) throw new Error(`Alert delivery failed: HTTP ${response.status}`)
}
}
async #poll() {
if (this.#checking) return
this.#checking = true
try {
for (const [url, hash] of this.#entries) {
try {
await this.#check(url, hash)
} catch (error) {
console.error('SRI monitor check failed:', error)
}
}
} finally {
this.#checking = false
}
}
start() {
if (this.#intervalId !== undefined) return
this.#intervalId = setInterval(() => {
void this.#poll()
}, this.interval)
}
stop() {
clearInterval(this.#intervalId)
this.#intervalId = undefined
}
}
After defining generateSRIHash, resource, and the class, try this in the demo’s console:
const monitor = new SRIMonitor(1000)
monitor.add(new URL('/changed.js', resource.src).href, resource.integrity)
monitor.start()
// Run monitor.stop() when finished.
The browser logs a mismatch and the server prints Local SRI alert received. The loopback receiver acknowledges and discards alerts; it provides no authentication, storage, or external notifications. For production, supply an authenticated, rate-limited endpoint and avoid credential-bearing URLs in reports. A network error or CORS failure is a failed check, not evidence that content changed.
Automate SRI inside your CI/CD pipeline
Generate hashes after your trusted build’s final minification step, before rendering the HTML.
Run sri.sh for each intended script or stylesheet and fail the build on any nonzero status; also
fail if the expected asset list is empty. Store each filename and SRI value in your build manifest,
then deploy that manifest’s assets and rendered HTML together. This integration depends on your
build system; the local demo above does not install a CI workflow.
Prefer versioned asset URLs over mutable aliases such as latest. An intentional dependency update
requires review of the new release, followed by a new asset URL and its new expected hash. Do not
make a failing integrity check automatically update the expected value.
Troubleshoot common pitfalls
| Symptom | What to check |
|---|---|
| Hash command fails | Check the path, permissions, and OpenSSL installation. Missing input must not become an empty-file hash. |
| Browser reports an integrity mismatch | Compare the response with the approved release or build artifact. Investigate an unexpected change; update the hash only after approving the new bytes. |
| Local hash differs from CDN bytes | Check minification, injected banners, and line endings. Ordinary HTTP compression is decoded before the integrity check. |
| Browser reports a CORS failure | Keep crossorigin="anonymous" and configure the CDN’s Access-Control-Allow-Origin for your page, or * for public anonymous assets. |
| Script runs without protection | Inspect the actual element for valid integrity metadata and confirm browser support. Empty or unsupported metadata does not provide the intended check. |
