Base64 Encoding Rules for SRI Hashes
Permalink to "Base64 Encoding Rules for SRI Hashes"Part of Understanding Cryptographic Hash Algorithms, this page covers the encoding half of an integrity value — the part that turns a correct digest into a string a browser will accept, and the part that hand-built hashes almost always get wrong.
Quick Reference
Permalink to "Quick Reference"| Rule | Value |
|---|---|
| Token grammar | <algorithm>-<base64>, e.g. sha384-kfZ3… |
| Supported algorithm labels | sha256, sha384, sha512 |
| Encoded input | The raw digest bytes, never the hex string |
| Alphabet | Standard base64: A-Z, a-z, 0-9, +, / |
| Not permitted | base64url - and _; whitespace inside a token |
| Padding | = required — SHA-256 ends =, SHA-512 ends == |
| Encoded length | SHA-256 44 chars, SHA-384 64 chars, SHA-512 88 chars |
| Multiple hashes | Space-separated in one attribute; strongest algorithm wins |
| Separator | One or more ASCII whitespace characters between tokens |
The mental model
Permalink to "The mental model"An integrity value is not one string, it is a set of tokens, and each token is two fields glued by a hyphen. The left field is an algorithm label the browser recognises. The right field is the base64 encoding of the digest that algorithm produces — and the crucial word is digest, meaning the raw bytes the hash function emits, not the human-readable hexadecimal rendering that every command line tool prints by default.
That distinction is the single largest source of broken integrity attributes. A SHA-384 digest is 48 bytes. Those 48 bytes can be rendered as 96 hexadecimal characters for humans, or encoded as 64 base64 characters for the attribute. Encoding the hex rendering into base64 is a double encoding: you are base64-ing 96 ASCII characters rather than 48 binary bytes, which yields a 128-character string that is both the wrong length and semantically meaningless to the browser.
The hyphen carries a second responsibility that trips people up. Because the algorithm label and the digest are separated by -, a hyphen cannot appear inside the digest field. That rules out base64url, the URL-safe variant that replaces + with - and / with _. SRI wants plain, standard base64 from RFC 4648 section 4, padding included. Real integrity values in the wild routinely contain + and /, and both are entirely legal.
Length, padding and why SHA-384 is the forgiving one
Permalink to "Length, padding and why SHA-384 is the forgiving one"Base64 packs three input bytes into four output characters. When the input length is not a multiple of three, the encoder emits = characters to fill out the final quartet. Because each hash algorithm has a fixed digest size, the finished token has a fixed, checkable length — which makes length the cheapest possible smoke test for a hand-built value.
SHA-256 produces 32 bytes. Thirty-two is not a multiple of three, so the last quartet carries two bytes and one =: 44 characters total. SHA-512 produces 64 bytes, whose last quartet carries a single byte and two =: 88 characters. SHA-384 produces 48 bytes, an exact multiple of three, so it encodes to 64 characters with no padding at all. That is a quiet practical argument on top of the usual reasons to prefer it, discussed in How to Calculate SHA-256 vs SHA-384 for SRI: with SHA-384 there is no trailing = for a spreadsheet, a template engine or an over-eager sanitiser to eat.
Padding is not decorative. The browser computes the digest itself, base64-encodes it with padding, and compares that string to the one you supplied. Strip the = from a SHA-256 value and the comparison fails even though the underlying bytes are identical.
Canonical example: the shell one-liner that is actually correct
Permalink to "Canonical example: the shell one-liner that is actually correct"Two flags do all the work. -binary makes OpenSSL emit the raw digest instead of the SHA2-384(app.js)= … hex line, and -A makes the base64 stage put everything on one line instead of wrapping.
# Correct: raw digest bytes, single-line standard base64
printf 'sha384-%s\n' "$(openssl dgst -sha384 -binary dist/app.js | openssl base64 -A)"
Expected shape of the output — one sha384- prefix and exactly 64 base64 characters:
sha384-kfZ3Bv0Xq2Rr9NwT7yMcLd4Pj6HsGaEu1ZoWiVbYnQxA5UtCeD8ImJlKrSgFpOh2
Drop that straight into the tag. Any element carrying integrity for a cross-origin URL also needs crossorigin, because the browser must make a CORS-enabled request before it is allowed to read the bytes it is about to hash:
<script src="https://cdn.example.com/app.4f2c9a.js"
integrity="sha384-kfZ3Bv0Xq2Rr9NwT7yMcLd4Pj6HsGaEu1ZoWiVbYnQxA5UtCeD8ImJlKrSgFpOh2"
crossorigin="anonymous"></script>
Now the version that looks nearly identical and is completely wrong:
# WRONG: base64 of the printed hex line, including its "SHA2-384(...)= " prefix
openssl dgst -sha384 dist/app.js | base64
Without -binary, OpenSSL prints a human line — SHA2-384(dist/app.js)= 91f7… on OpenSSL 3, or SHA384(dist/app.js)= 91f7… on 1.1.1 — and the pipe faithfully base64-encodes that entire line, filename and trailing newline included. Even if you strip the label and encode only the 96 hex characters, you get 128 base64 characters instead of 64. The length check alone catches both mistakes instantly.
Variants
Permalink to "Variants"Node.js
Permalink to "Node.js"Node’s crypto module encodes for you, so the only decision is which digest encoding you ask for. Read the file as a Buffer — never as a UTF-8 string — so the bytes hashed are exactly the bytes on disk:
// sri.mjs — usage: node sri.mjs dist/app.js
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const bytes = readFileSync(process.argv[2]); // Buffer, not a string
const digest = createHash('sha384').update(bytes).digest('base64');
console.log(`sha384-${digest}`);
digest('base64') returns standard, padded base64. Node also accepts digest('base64url'), which produces exactly the encoding SRI rejects — a single character difference in your source that silently ships an unenforceable attribute. There is no reason to use it here.
Python
Permalink to "Python"The same shape, with the same one-word trap: .digest() returns bytes, .hexdigest() returns the 96-character string you must not encode.
#!/usr/bin/env python3
# usage: python3 sri.py dist/app.js
import base64, hashlib, sys
with open(sys.argv[1], "rb") as fh: # binary mode matters
digest = hashlib.sha384(fh.read()).digest() # raw bytes, not hexdigest()
print("sha384-" + base64.b64encode(digest).decode("ascii"))
Use base64.b64encode, not base64.urlsafe_b64encode. The urlsafe variant swaps in - and _ and will produce a token the browser cannot parse whenever the digest happens to contain either symbol — roughly three quarters of the time for a 48-byte digest, which makes it an intermittent bug rather than an obvious one.
Several hashes in one attribute
Permalink to "Several hashes in one attribute"The attribute holds a set, not a single value. Tokens are separated by ASCII whitespace, and the browser evaluates them by first selecting the strongest algorithm present that it supports, then passing the resource if its digest matches any token using that algorithm:
<link rel="stylesheet" href="https://cdn.example.com/ui.9c31be.css"
integrity="sha384-kfZ3Bv0Xq2Rr9NwT7yMcLd4Pj6HsGaEu1ZoWiVbYnQxA5UtCeD8ImJlKrSgFpOh2 sha512-kfZ3Bv0Xq2Rr9NwT7yMcLd4Pj6HsGaEu1ZoWiVbYnQxA5UtCeD8ImJlKrSgFpOh2TvXwYzB3cD5eF7gH9jKlmA=="
crossorigin="anonymous">
Here the SHA-384 token is dead weight: SHA-512 is stronger and supported, so only the SHA-512 entry is ever compared. Listing weaker algorithms alongside stronger ones neither hardens nor weakens the check — it just adds bytes. The genuinely useful case for multiple tokens is several acceptable builds of the same asset under one algorithm, for example during a rollout where two artefacts are legitimately in flight. The same set semantics apply to the manifest form described in Using the Import Map integrity Key.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
A newline inside the value splits it into two dead tokens. GNU
base64wraps output at 76 columns by default, so an 88-character SHA-512 value arrives with a line break in the middle. Pasted into an attribute, the browser splits on that whitespace and gets two fragments, neither of which parses. Useopenssl base64 -A, which is portable, or pipe throughtr -d '\n'; the GNU-onlybase64 -w 0is not available on macOS. -
A malformed attribute fails open, not closed. Tokens the browser cannot parse, and tokens naming an algorithm it does not support, are discarded. If discarding leaves the set empty, the fetch proceeds as though no
integrityattribute existed — the resource loads, nothing appears in the console, and you have a control you believe is protecting you that is not. This is why a shape check belongs in the build, not in a code review. -
Omitting
crossoriginon a cross-origin URL breaks the check for a different reason. Without a CORS-enabled request the response is opaque, the browser cannot read the body, and the load fails regardless of how well-formed your base64 is. The console message points at integrity, which sends people hunting for an encoding bug that is not there — see How CORS and crossorigin Affect SRI. -
Hash the decoded body, not the wire bytes. The digest is taken over the response body after any content encoding has been removed, so a gzipped or Brotli-compressed transfer does not change the value. When you fetch an asset to compute its hash, make sure your client decompresses:
curl -sL --compressedis safe, while saving a raw compressed stream and hashing that produces a value nothing will ever match. -
Trailing
?options are legal and must not be trimmed. The grammar permits an option suffix after the base64 value, and browsers ignore options they do not recognise. Tooling that “cleans” integrity strings by cutting at the first?will silently alter values that legitimately carry one.
Verification Steps
Permalink to "Verification Steps"1. Check the length before anything else
Permalink to "1. Check the length before anything else"VAL=$(openssl dgst -sha384 -binary dist/app.js | openssl base64 -A)
printf '%s' "$VAL" | wc -c
Expected output for SHA-384:
64
Anything else means the wrong bytes reached the encoder. 96 is the hex digest, 128 is base64 of the hex digest, and a value one or two characters short means padding was stripped.
2. Validate the whole token with a regex
Permalink to "2. Validate the whole token with a regex"Pin the alphabet, the length and the padding in one expression, then run it in CI over every integrity value your build emits:
RE='^(sha256-[A-Za-z0-9+/]{43}=|sha384-[A-Za-z0-9+/]{64}|sha512-[A-Za-z0-9+/]{86}==)$'
printf 'sha384-%s\n' "$VAL" | grep -Eq "$RE" && echo "well-formed" || echo "REJECT"
Expected output:
well-formed
A hyphen or underscore in the digest field, a missing =, or a doubled encoding all fail this test. For an attribute holding several space-separated tokens, split on whitespace first and run each token through the same pattern.
3. Round-trip the value back to bytes
Permalink to "3. Round-trip the value back to bytes"A well-formed token should decode to exactly the digest length. This catches non-canonical padding that a length check alone would miss:
printf '%s' "$VAL" | openssl base64 -d -A | wc -c
Expected output:
48
4. Confirm the browser agrees
Permalink to "4. Confirm the browser agrees"Load the page, open DevTools and check the network entry for the asset. A parseable-but-wrong value produces an explicit console error naming the computed and expected digests, which is the good failure — the reader of Debugging SRI Hash Mismatch Errors can work from that message. Silence plus a successfully loaded script means the metadata set came out empty and nothing was verified at all; re-run step 2 on the exact string in the served HTML, not the one in your template source.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Why is my SHA-384 value 96 characters long?
Because it is the hexadecimal digest, not base64. A SHA-384 digest is 48 bytes, which is 96 characters in hex and 64 characters in base64. Command line digest tools print hex by default, so you must ask for raw output with a flag such as openssl dgst -binary and encode that instead.
Can I use base64url encoding in an integrity attribute?
No. The SRI grammar admits only the standard base64 alphabet, in which the last two symbols are plus and slash. The base64url substitutes hyphen and underscore, and a hyphen is also the separator between the algorithm label and the digest, so a base64url value is either rejected outright or fails the value comparison.
Is the = padding required in an SRI hash?
Yes. The browser encodes the digest it computed with padding and compares that string to yours, so a stripped trailing = will not match. This only affects SHA-256, which ends in one =, and SHA-512, which ends in two. A SHA-384 digest is 48 bytes, an exact multiple of three, so it is never padded.
What happens if the integrity attribute is malformed?
Nothing visible, which is the danger. The browser discards tokens it cannot parse or whose algorithm it does not support. If every token is discarded, the metadata set is empty and the fetch is treated as if no integrity attribute were present, so the resource loads unverified with no console error.
Does listing both a sha256 and a sha384 hash make a page more secure?
No. When several algorithms appear in one attribute, the browser selects the strongest one it supports and compares only against hashes using that algorithm. The weaker entries are ignored, so they neither add protection nor weaken it. Multiple values are useful for listing several acceptable builds under one algorithm.
Related
Permalink to "Related"- SHA-256 vs SHA-384 vs SHA-512 for SRI — which algorithm label to put before the hyphen, and what the extra digest bytes actually buy you
- Generating SRI Hashes with OpenSSL and shasum — the full command line workflow for batches of assets, including the
shasumandxxdroute - Debugging SRI Hash Mismatch Errors — reading the browser’s failure message when the encoding is right but the value still does not match