SRI for Self-Hosted Web Fonts
Permalink to "SRI for Self-Hosted Web Fonts"Part of Applying SRI to Stylesheets & Web Fonts, this page draws the exact line between what an integrity attribute pins when you host your own fonts and what it leaves untouched, then shows the caching, preloading and pipeline controls that stand in for the coverage the platform does not offer.
Quick Reference
Permalink to "Quick Reference"| Concern | Mechanism | Enforced by |
|---|---|---|
@font-face stylesheet bytes |
integrity="sha384-…" on <link rel="stylesheet"> |
Browser, at fetch time |
| Companion attribute | crossorigin="anonymous" |
Browser, required with integrity |
| woff2 binary bytes | nothing in the browser | — |
| Font request mode | Always CORS, same-origin or not | Fetch + CSS Fonts |
| Preload element | <link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"> |
Browser preload cache |
| Byte stability | Content-hashed filename + Cache-Control: immutable |
Origin/CDN headers |
| Origin restriction | CSP font-src 'self' |
Browser policy |
| Pre-release check | SHA-384 manifest verified in CI | Your pipeline |
| Directive coverage | require-sri-for covers script and style only |
— |
Default posture: one hashed CSS file, content-addressed woff2 filenames served immutable, preloads for the two above-the-fold faces, and a digest manifest checked on every deploy.
The mental model
Permalink to "The mental model"Self-hosting fonts collapses a two-origin problem into a one-origin problem, and that changes what SRI is for. When the CSS and the fonts both come from your own domain, the attacker you are defending against is no longer “a third party silently reshipping a different file”; it is “someone who obtained write access to a build output, an object store, or a CDN configuration”. SRI is a fetch-time gate: the browser reads the token in the markup, hashes the response body, and refuses to use the resource on mismatch. That gate exists for <script> and <link rel="stylesheet">. It does not exist for the font fetch, because the font fetch is not initiated by markup at all.
The chain is worth spelling out. Your document contains a <link rel="stylesheet"> pointing at a CSS file. That element accepts integrity, so the CSS bytes are pinned. The CSS file contains @font-face rules whose src descriptors name woff2 URLs. Those URLs are fetched later, by the CSS font loading algorithm, only when a matching character actually needs rendering. There is no attribute, descriptor or header that lets you attach a digest to that second fetch. Hashing the stylesheet therefore pins the instructions, not the payload: an attacker who can rewrite the CSS to point at https://evil.example/inter.woff2 is stopped, but an attacker who can overwrite inter-400.a3f19c7d.woff2 in place is not.
The same-origin argument is what makes this gap tolerable rather than alarming. SRI was designed for the case where you delegate delivery to somebody else and want a cryptographic receipt that they delivered what you agreed on. When the fonts sit on the same origin as the document, the trust boundary the receipt would guard has already been crossed: an adversary who can replace inter-400.woff2 on your origin can equally replace app.js, rewrite the HTML, and remove any integrity token you added. There is no residual risk that a font hash would have caught and everything else missed. The controls that matter shift left, into the build and deploy pipeline, and sideways, into cache semantics that make silent replacement observable. That is why the rest of this page spends more time on manifests and headers than on hash tokens.
Canonical example: hashed CSS plus preloaded faces
Permalink to "Canonical example: hashed CSS plus preloaded faces"This is the complete production pattern. One stylesheet declares every face, carries a SHA-384 token, and is loaded with crossorigin="anonymous"; the two faces needed for first paint are preloaded ahead of it.
/* dist/assets/fonts/inter.4f1c9a.css — the bytes covered by the integrity token */
@font-face {
font-family: "Inter";
src: url("/assets/fonts/inter-400.a3f19c7d.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC;
}
@font-face {
font-family: "Inter";
src: url("/assets/fonts/inter-600.9b2e04f1.woff2") format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC;
}
@font-face {
font-family: "Inter";
src: url("/assets/fonts/inter-italic.2c7b81.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
<!-- Preloads first: the fetch starts before the stylesheet is parsed.
crossorigin is mandatory here even though the URL is same-origin. -->
<link rel="preload" as="font" type="font/woff2"
href="/assets/fonts/inter-400.a3f19c7d.woff2"
crossorigin="anonymous" />
<link rel="preload" as="font" type="font/woff2"
href="/assets/fonts/inter-600.9b2e04f1.woff2"
crossorigin="anonymous" />
<!-- The only element in this block that can carry a digest -->
<link rel="stylesheet"
href="/assets/fonts/inter.4f1c9a.css"
integrity="sha384-Xk2pQ7mR4tV9yB1nE6sH3jC8dF0gA5uZ7xR2wN9tM4oP1qS6bV3cX0yG8hJ5kL2"
crossorigin="anonymous" />
Generate the token from the built artifact, never from the source file, since minification changes the bytes:
openssl dgst -sha384 -binary dist/assets/fonts/inter.4f1c9a.css | openssl base64 -A
# → Xk2pQ7mR4tV9yB1nE6sH3jC8dF0gA5uZ7xR2wN9tM4oP1qS6bV3cX0yG8hJ5kL2
The digest-emitting flags and their portable equivalents are covered in Generating SRI Hashes with OpenSSL and shasum.
The crossorigin attribute on the preload tags is not decoration and not a copy-paste artefact from the stylesheet. Font resources are always fetched in CORS mode — the CSS Fonts specification requires it, and the rule holds for same-origin URLs as well as cross-origin ones. A preload issued without crossorigin runs in no-CORS mode, producing a cached response whose request mode does not match the CORS-mode request the font loading algorithm makes moments later. The browser cannot reuse a no-CORS response for a CORS-mode request, so it discards the preload and downloads the font a second time. The page ends up slower than if you had never preloaded, and Chrome logs it:
A preload for '/assets/fonts/inter-400.a3f19c7d.woff2' is found, but is not
used because the request credentials mode does not match. Consider taking a
look at crossorigin attribute.
Note that crossorigin and crossorigin="anonymous" are the same thing — the attribute’s missing-value default is the anonymous state. Write it out explicitly anyway; the explicit form survives templating engines, HTML minifiers and code review better than a bare attribute.
Variants
Permalink to "Variants"Content-hashed filenames with an immutable cache policy
Permalink to "Content-hashed filenames with an immutable cache policy"This is the practical substitute for the integrity token the font fetch cannot carry. Derive each woff2 filename from the digest of its own bytes, then serve it with a year-long immutable freshness lifetime:
location ^~ /assets/fonts/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
types { font/woff2 woff2; }
}
Two properties fall out of this. First, the URL is a commitment: inter-400.a3f19c7d.woff2 is defined as the file whose digest starts a3f19c7d, so replacing the bytes without changing the name is by construction a lie the pipeline can detect. Second, immutable tells the browser never to revalidate within the freshness window, which means a poisoned intermediary cache cannot wait for a conditional request to slip in a different body — there are no conditional requests. Content addressing does not stop tampering the way a hash check does; it makes tampering nameable, which is what turns it into something CI can assert about.
A digest manifest checked in CI
Permalink to "A digest manifest checked in CI"Write the manifest as part of the build, commit it or publish it as a release artefact, and re-verify after deploy. sha384sum produces the hex form; that is fine here because the manifest is consumed by your pipeline, not by a browser.
# Build step — record every shipped font
( cd dist && find assets/fonts -name '*.woff2' -print | sort \
| xargs sha384sum ) > dist/assets/fonts/SHA384SUMS
# Pre-deploy gate — the artefacts must still match what the build produced
( cd dist && sha384sum -c assets/fonts/SHA384SUMS )
# → assets/fonts/inter-400.a3f19c7d.woff2: OK
The post-deploy half re-downloads each file from the live origin and compares it to the recorded digest, which is what actually catches an edge transform or an object-store overwrite:
#!/usr/bin/env bash
set -euo pipefail
base="https://www.example.com"
fail=0
while read -r digest path; do
live=$(curl -fsSL "$base/$path" | sha384sum | cut -d' ' -f1)
if [ "$live" != "$digest" ]; then
echo "DRIFT: $path (expected ${digest:0:16}…, got ${live:0:16}…)"
fail=1
fi
done < dist/assets/fonts/SHA384SUMS
exit "$fail"
The general shape of this check, including how to store the manifest and wire the failure into a deployment gate, is developed in Verifying Deployed Assets Against a Hash Manifest.
Preloading via the Link response header
Permalink to "Preloading via the Link response header" If your HTML is generated by a cache or an edge function that cannot easily inject markup, the same preloads can be emitted as response headers. The crossorigin requirement is identical.
add_header Link '</assets/fonts/inter-400.a3f19c7d.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin=anonymous' always;
Prefer markup where you can: header-driven preloads start earlier but are harder to keep in sync with the content-hashed filenames the build emits, and a stale header points at a URL that no longer exists.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
Omitting
crossoriginon the stylesheet blocks it outright. Any<link>carryingintegritymust also carrycrossorigin="anonymous". Without it a cross-origin request is issued in no-CORS mode, the body is opaque, the browser cannot hash what it cannot read, and the stylesheet is discarded before the comparison ever happens. The failure looks like a hash mismatch but is really a readability failure — the distinction and the header requirements behind it are unpacked in How CORS and crossorigin Affect SRI. -
A preload without
crossorigincosts you a second download. This is the one rule people get wrong on same-origin setups, because every other preload type (as="script",as="style",as="image") works fine without it. Fonts do not, because their fetch is unconditionally CORS-mode. The symptom is two identical woff2 entries in the network panel and a console warning about credentials mode, not an error. -
integrityon a font preload is not a font hash. You may see<link rel="preload" as="font" integrity="…">suggested. Even where the preload response itself is validated, the CSS font loading algorithm performs its own match against thesrcURL and does not inherit or require that token, so a tampered font served later is still used. Treat font preload integrity as unreliable and do not present it as coverage. -
require-sri-for fontdoes not exist. Therequire-sri-fordirective was only ever specified for thescriptandstyletokens, and it has limited engine support even for those. There is no policy switch that mandates integrity on font fetches. What you can enforce is origin:font-src 'self'in your CSP, which caps the blast radius of a rewrittensrcdescriptor. Combining that with script and style controls is covered in Configuring Content Security Policy with SRI. -
Subsetting changes the bytes, so it changes both hashes. Re-running a subsetter with a different
unicode-rangeor a different tool version emits different woff2 output, which changes the font digest, its content-hashed filename, thesrcURL in the CSS, and therefore the CSS digest too. Rebuild the stylesheet token whenever fonts are regenerated; a font pipeline that runs independently of the CSS pipeline will ship a mismatched pair. -
Edge compression is safe, edge rewriting is not. woff2 is already compressed and most CDNs skip it, but even if a proxy applies
Content-Encoding, SRI hashes the decoded body, so the stylesheet token survives. What breaks things is a CDN feature that rewrites URLs inside CSS or strips comments; disable payload-mutating rules on the font stylesheet path.
Verification Steps
Permalink to "Verification Steps"1. Confirm the stylesheet token matches the served bytes
Permalink to "1. Confirm the stylesheet token matches the served bytes"curl -fsSL https://www.example.com/assets/fonts/inter.4f1c9a.css \
| openssl dgst -sha384 -binary | openssl base64 -A
The output must equal the value after sha384- in the markup, character for character. A trailing newline in the terminal is from your shell prompt, not the digest.
2. Prove the font request really is CORS-mode
Permalink to "2. Prove the font request really is CORS-mode"curl -sI -H "Origin: https://www.example.com" \
https://www.example.com/assets/fonts/inter-400.a3f19c7d.woff2
Expect a 200 alongside both of these headers; the absence of the first is what silently breaks cross-origin font delivery:
access-control-allow-origin: *
cache-control: public, max-age=31536000, immutable
3. Confirm the preload is reused rather than duplicated
Permalink to "3. Confirm the preload is reused rather than duplicated"Load the page in Chrome with the Network panel open and filter on woff2. Each preloaded face must appear exactly once, with preload as the initiator. Two rows for the same URL, or a console line reading A preload for … is found, but is not used because the request credentials mode does not match, means a crossorigin attribute is missing from the preload tag.
4. Verify the deployed font bytes against the manifest
Permalink to "4. Verify the deployed font bytes against the manifest"bash scripts/verify-fonts.sh; echo "exit: $?"
A clean run prints nothing and exits 0. Corrupt a single byte in one deployed file and re-run to prove the gate trips:
DRIFT: assets/fonts/inter-400.a3f19c7d.woff2 (expected 9f21c0a4bd7e13f8…, got 41ba07c9e2d5f6aa…)
exit: 1
5. Sanity-check the file is a real woff2
Permalink to "5. Sanity-check the file is a real woff2"A truncated upload or a proxy error page saved under a .woff2 name fails silently in the browser as an unusable font. The first four bytes of a valid file are the wOF2 signature:
head -c 4 dist/assets/fonts/inter-400.a3f19c7d.woff2 | xxd
# → 00000000: 774f 4632 wOF2
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Can I put an integrity attribute on a woff2 file?
No. Fonts are requested by the CSS font loading machinery from a src descriptor inside @font-face, and neither that descriptor nor any HTML element exposes a place to declare a digest for the resulting fetch. Even link rel="preload" as="font" ignores integrity for the purpose of font matching, so there is no supported way to make a browser reject a tampered font binary.
Why does a same-origin font preload need crossorigin="anonymous"?
Font fetches always run in CORS mode, regardless of origin. A preload without crossorigin is issued in no-CORS mode, so its response never matches the CORS-mode request the CSS engine makes later. The preload is discarded, the font downloads a second time, and the page renders slower than with no preload at all.
Does the stylesheet hash protect the font binaries at all?
Indirectly, and only in one dimension. The hash guarantees the set of src URLs the browser will request has not been rewritten, so an attacker who controls the CSS cannot silently repoint your fonts at an attacker-controlled host. It says nothing about the bytes returned from those URLs, which is a separate and unprotected fetch.
What actually stops someone who can write to my asset storage?
Content-hashed filenames plus a digest manifest verified after deploy. If the filename is derived from the bytes, overwriting the file in place makes the deployed digest disagree with the manifest recorded at build time, and the post-deploy check fails the release. Nothing in the browser will catch it, so the check has to live in the pipeline.
Should I preload every font file I ship?
No. Preload only the faces rendered above the fold, typically one regular and one bold weight of the primary family. Every preload competes for early bandwidth with the stylesheet and the document itself, and an unused preload triggers a console warning and wastes the connection. Italic, extended-subset and secondary families should load lazily through @font-face as usual.
Related
Permalink to "Related"- Why Font Files Cannot Carry an integrity Attribute — the specification-level reason the font fetch has no place to hang a digest
- Adding Integrity to Google Fonts and CSS — the hosted-CSS counterpart, including the per-browser byte-instability trap
- Static Asset Hash Generation — wiring content-hashed filenames and digest emission into your build