Generating SRI Hashes with OpenSSL and shasum

Permalink to "Generating SRI Hashes with OpenSSL and shasum"

Part of Static Asset Hash Generation, this page is the command-line reference for turning a file on disk or a URL into a correct integrity value, and for spotting the four or five pipelines that look right and silently produce a value no browser will ever accept.

Quick Reference

Permalink to "Quick Reference"
Task Command Notes
Canonical one-liner openssl dgst -sha384 -binary f.js | openssl base64 -A Portable across macOS, BSD and Linux
shasum equivalent shasum -b -a 384 f.js | awk '{print $1}' | xxd -r -p | base64 xxd -r -p converts hex back to bytes
GNU coreutils digest sha384sum f.js Hex only; not present on macOS
From a URL curl -fsSL URL | openssl dgst -sha384 -binary | openssl base64 -A -f stops you hashing a 404 body
Suppress base64 wrapping openssl base64 -A or base64 -w0 -w0 is GNU-only
Attribute value sha384- + 64 base64 characters sha256- is 44 chars, sha512- is 88

The algorithm prefix is part of the value, not a separate attribute. SHA-384 is the default choice here; the trade-offs are laid out in SHA-256 vs SHA-384 vs SHA-512 for SRI.

The mental model

Permalink to "The mental model"

An integrity value is a two-part string: an algorithm token, a hyphen, and the base64 encoding of the digest’s raw bytes. Everything that goes wrong at the command line goes wrong because a shell pipeline naturally wants to hand you text, and every general-purpose checksum tool prints its digest as hexadecimal text. Hex is a display format. It is twice as long as the digest it represents and it is not what you encode.

SHA-384 produces 48 bytes. Base64 expands three bytes into four characters, so 48 bytes becomes exactly 64 characters with no padding. That constant is the single most useful debugging fact on this page: if the string after sha384- is not 64 characters, the pipeline is wrong before a browser ever sees it. A hex digest fed to base64 yields 132 characters, and a hex digest pasted verbatim yields 96. Both are visibly wrong at a glance.

The other half of the model is which bytes. The browser hashes the response body it received, after any Content-Encoding has been stripped. So the file you hash locally must be byte-identical to what the origin ultimately serves, not merely semantically equivalent. Any step between your shell and the user’s browser that rewrites the payload — a CDN minifier, a deploy script that stamps a build number into the bundle, a Git checkout that normalises line endings — invalidates the value silently.

SRI hash generation pipeline The asset file is read by openssl dgst with the sha384 and binary flags, producing 48 raw digest bytes, which openssl base64 with the A flag encodes into 64 characters that are prefixed with sha384 to form the integrity attribute value. dist/app.js served bytes openssl dgst -sha384 -binary 48 raw bytes no hex, no text openssl base64 -A integrity="sha384-" + 64 base64 characters prefix names the algorithm, base64 carries the raw digest

Canonical example: one file, one value

Permalink to "Canonical example: one file, one value"

Everything below is reproducible. Create a known file, hash it, and compare against the printed value.

printf 'console.log("hi");\n' > demo.js
openssl dgst -sha384 -binary demo.js | openssl base64 -A

That prints, with no trailing newline:

tdnWtBkj5+038HkeFOzlN0GdBuwDpXaWFs1Dhs560d67HmXunuEg4R3e+iEGk0Ho

Wrap it in the prefix and emit an attribute-ready string in one step:

printf 'sha384-%s\n' "$(openssl dgst -sha384 -binary demo.js | openssl base64 -A)"

Then attach it to the tag. The crossorigin attribute is not optional for a cross-origin asset — without it the response is opaque, the browser has nothing to hash, and the request fails outright:

<script
  src="https://cdn.example.com/lib/demo.js"
  integrity="sha384-tdnWtBkj5+038HkeFOzlN0GdBuwDpXaWFs1Dhs560d67HmXunuEg4R3e+iEGk0Ho"
  crossorigin="anonymous"
  referrerpolicy="no-referrer"></script>

The interaction between the two attributes, and why a same-origin asset behaves differently, is covered in How CORS and crossorigin Affect SRI.

Variants

Permalink to "Variants"

The shasum route, and why it needs xxd

Permalink to "The shasum route, and why it needs xxd"

shasum is a Perl script that ships with macOS and with most Perl installations, and it only speaks hex. To get back to bytes you have to undo the hex encoding explicitly:

shasum -b -a 384 demo.js | awk '{print $1}' | xxd -r -p | base64 -w0

This prints the identical 64 characters as the OpenSSL pipeline. Three details matter. -a 384 selects the algorithm — the default is SHA-1, which is not a valid SRI algorithm. -b opens the file in binary mode, which is a no-op on Unix but prevents line-ending translation on Windows and Cygwin. And xxd -r -p is the step that converts the plain hex transcript back into the 48 bytes you actually want; drop it and you encode the wrong thing.

On GNU systems sha384sum replaces shasum -a 384 and produces the same hex, so sha384sum demo.js | cut -d' ' -f1 | xxd -r -p | base64 -w0 is the coreutils spelling.

Platform differences worth memorising

Permalink to "Platform differences worth memorising"

The base64 encoder is where portability breaks. GNU coreutils base64 wraps output at 76 columns by default; a 64-character SHA-384 value squeaks under that limit and looks fine, but an 88-character SHA-512 value gets split across two lines and the resulting attribute is garbage. openssl base64 wraps at 64 columns, which splits SHA-512 too. Only -A (OpenSSL) or -w0 (GNU) reliably suppress wrapping, and -w0 does not exist on macOS. Use openssl base64 -A if the script has to run in both places.

macOS versus GNU command matrix A four-row table comparing how each step of SRI hash generation is spelled on macOS or BSD versus on GNU coreutils, covering the hex digest, the raw digest, hex to byte conversion, and unwrapped base64 encoding. step macOS / BSD GNU coreutils hex digest shasum -b -a 384 f.js sha384sum f.js raw digest bytes openssl dgst -sha384 -binary openssl dgst -sha384 -binary hex text to bytes xxd -r -p xxd -r -p base64, one line openssl base64 -A base64 -w0 (no -w on macOS)

Hashing straight from a URL

Permalink to "Hashing straight from a URL"

To pin a third-party asset you already reference, hash what the CDN actually returns rather than a local copy you hope matches:

curl -fsSL https://cdn.example.com/lib/demo.js \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

-f makes curl exit non-zero on an HTTP error instead of piping the error page into the hash, which is how people end up publishing the digest of a 404. -L follows the redirect that most CDNs use to resolve a version path. -s and -S silence the progress meter while keeping real errors visible. Do not add -H 'Accept-Encoding: gzip' by hand: curl will then hand you the compressed bytes undecoded, and you will hash the gzip stream rather than the payload. Plain curl sends no Accept-Encoding at all and --compressed decodes for you, so both are safe.

Batch-hashing a directory into a manifest

Permalink to "Batch-hashing a directory into a manifest"

For a build output directory, emit a JSON map from path to integrity value. Downstream templating, and the deploy-time check described in Verifying Deployed Assets Against a Hash Manifest, both read this file.

#!/usr/bin/env bash
set -euo pipefail

dist="${1:-dist}"
first=1

printf '{\n'
while IFS= read -r -d '' f; do
  digest="sha384-$(openssl dgst -sha384 -binary "$f" | openssl base64 -A)"
  rel="${f#"$dist"/}"
  [ "$first" -eq 1 ] || printf ',\n'
  first=0
  printf '  "%s": "%s"' "$rel" "$digest"
done < <(find "$dist" -type f \( -name '*.js' -o -name '*.css' \) -print0)
printf '\n}\n'

Reading the find output through process substitution rather than a pipe keeps the loop in the current shell, so first survives the iteration. The -print0 and -d '' pairing keeps paths with spaces intact. Redirect the script’s output to dist/sri-manifest.json and commit it as a build artifact.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Base64-encoding the hex digest instead of the raw bytes. shasum -a 384 f.js | awk '{print $1}' | base64 looks plausible and produces a string starting YjVkOWQ2... that is 132 characters long. It is the base64 of the 96-character hex text, not of the 48-byte digest. Any SHA-384 value that is not exactly 64 characters is this bug. Insert xxd -r -p before the encoder.

  • Newline contamination from an unwrapped encoder. Both openssl base64 and GNU base64 insert line breaks by default, at 64 and 76 columns respectively. SHA-384 happens to fit within both limits so the bug hides; switch the same script to SHA-512 and the 88-character value is split in two, and the attribute silently becomes invalid. Always pass -A or -w0. Note that command substitution with $(...) strips a trailing newline but does not remove interior ones.

  • Omitting crossorigin="anonymous" on a cross-origin tag. An integrity attribute on a cross-origin <script> or <link> without crossorigin yields an opaque response the browser cannot read, so the check cannot run and the resource is blocked. The console message reads like a hash failure but no hash was ever computed. Add crossorigin="anonymous" to every cross-origin tag that carries integrity.

  • Hashing a file the server will not serve byte-for-byte. A CDN with automatic minification, an edge worker that injects a script, or a deploy step that appends a build banner all rewrite the payload after you computed the digest. Transport compression is fine — the browser decodes before checking — but payload rewriting is fatal. Hash the artifact at the last point it is still immutable, or hash the deployed URL.

  • Line endings and the invisible one-byte difference. The same source with CRLF endings hashes to sha384-7upxsQ5V9QhrMOgxqiLxY2JKhlXnVmZ/6EY86lzOCjnYsv8Sqiijv7DGScIlK19D while the LF copy hashes to the value shown earlier. A .gitattributes rule, a Windows checkout, or an editor that normalises on save changes the digest with no visible change to the file. When a value mismatches for no apparent reason, run wc -c on both copies before anything else — the diagnostic path is set out in Debugging SRI Hash Mismatch Errors.

Verification Steps

Permalink to "Verification Steps"

The point of a verification pass is that it recomputes the digest independently and compares it to the string already committed in the HTML. Two derivations, one comparison.

Verification sequence A verifier script fetches the deployed asset, recomputes its SHA-384 digest, reads the declared integrity value out of the built HTML, and exits non-zero when the two strings differ. dist/index.html verifier script deployed asset curl -fsSL raw bytes grep integrity declared value strings equal exit 0 hash drift exit 1

1. Confirm the value is well formed

Permalink to "1. Confirm the value is well formed"
printf 'sha384-%s' "$(openssl dgst -sha384 -binary demo.js | openssl base64 -A)" | wc -c

Expected output:

71

Seven characters of prefix plus 64 of base64. Anything else means the pipeline emitted hex, wrapped the encoding, or picked a different algorithm.

2. Cross-check the two toolchains against each other

Permalink to "2. Cross-check the two toolchains against each other"
diff <(openssl dgst -sha384 -binary demo.js | openssl base64 -A) \
     <(shasum -b -a 384 demo.js | awk '{print $1}' | xxd -r -p | openssl base64 -A)

diff prints nothing and exits 0. If it reports a difference, one branch is encoding hex — inspect which side is 132 characters long.

3. Re-derive from the file and diff against the HTML

Permalink to "3. Re-derive from the file and diff against the HTML"

This is the check to run in CI. It extracts every src/integrity pair from the built markup, recomputes each digest from the file on disk, and fails on the first divergence.

#!/usr/bin/env bash
set -euo pipefail

html="${1:-dist/index.html}"
root="$(dirname "$html")"
status=0

while read -r src declared; do
  file="$root/${src#/}"
  if [ ! -f "$file" ]; then
    printf 'MISSING %s\n' "$src"; status=1; continue
  fi
  actual="sha384-$(openssl dgst -sha384 -binary "$file" | openssl base64 -A)"
  if [ "$actual" = "$declared" ]; then
    printf 'OK      %s\n' "$src"
  else
    printf 'DRIFT   %s\n  html: %s\n  file: %s\n' "$src" "$declared" "$actual"
    status=1
  fi
done < <(grep -oE '<script[^>]*src="[^"]+"[^>]*integrity="[^"]+"[^>]*>' "$html" \
         | sed -E 's/.*src="([^"]+)".*integrity="([^"]+)".*/\1 \2/')

exit "$status"

Expected output on a healthy build:

OK      /assets/app.4f21c9.js
OK      /assets/vendor.9ab30e.js

The extraction assumes src appears before integrity in the tag; if your templating emits them the other way round, add a second sed branch or parse the markup with a real HTML parser. Note that this validates the files on disk. To validate what the origin serves, replace the openssl dgst line with the curl -fsSL … | openssl dgst -sha384 -binary form and point src at the deployed base URL.

4. Confirm the browser agrees

Permalink to "4. Confirm the browser agrees"

Load the page with DevTools open. A correct value produces no console output at all. A wrong one produces a message naming the computed digest, which you can paste straight back into a comparison. The encoding rules that decide whether a value is even parseable — padding, alphabet, whitespace — are set out in Base64 Encoding Rules for SRI Hashes.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why does shasum -a 384 file.js | base64 produce the wrong integrity value?

Because it base64-encodes the ASCII hex transcript of the digest rather than the 48 raw bytes the digest actually is. A SHA-384 integrity value is always 64 base64 characters; that pipeline emits 132. The fix is to convert the hex back to bytes with xxd -r -p before encoding, or to skip hex entirely with openssl dgst -sha384 -binary.

Does gzip or Brotli compression change the SRI hash?

No. Content-Encoding is a transport concern and the browser removes it before the integrity check runs, so the digest covers the decoded payload. What does change the value is a transform that rewrites the payload itself, such as CDN auto-minification, edge script rewriting, or a rebuild that re-emits the file with different output.

Which base64 flavour does the integrity attribute expect?

Standard base64 as defined in RFC 4648 section 4, with the plus and slash characters and trailing equals padding. That is what openssl base64 and the base64 utility emit by default. The URL-safe alphabet that substitutes hyphen and underscore is not what the grammar describes, so do not run the value through a URL-safe encoder.

Can I generate an SRI hash straight from a CDN URL?

Yes, pipe curl into openssl: curl -fsSL URL | openssl dgst -sha384 -binary | openssl base64 -A. Always include -f so an HTTP error page is not hashed as if it were the asset, and -L so a redirect is followed. Pin the URL to an immutable version path, because hashing a floating tag guarantees a mismatch on the next release.

Why do my local hash and the deployed hash differ for the same file?

Almost always line endings or a post-build transform. A checkout with CRLF endings hashes differently from the LF copy the build emitted, and a single byte changes the entire digest. Deployment pipelines that append a build banner, strip source-map comments, or run an edge minifier also alter the bytes after you computed the value.

Permalink to "Related"

Related Articles

Automating Hash Generation in Webpack 5
Generating SRI Hashes in Vite
Adding SRI to Rollup and esbuild Builds
Static Asset Hash Generation Asset Hashing & Dynamic Script…