Applying SRI to Stylesheets & Web Fonts
Permalink to "Applying SRI to Stylesheets & Web Fonts"This workflow is part of Core SRI Fundamentals & Browser Security Boundaries. Stylesheets look like a straightforward extension of script hashing — attach an integrity attribute to the <link rel="stylesheet"> and the browser blocks any tampered CSS — but the moment that CSS contains an @font-face rule the protection quietly stops at the stylesheet boundary. The font files it pulls in are fetched as independent subresources with no integrity mechanism of any kind, and the most common way teams load fonts, the Google Fonts CSS endpoint, returns different bytes to different browsers so its hash never stabilises.
This page walks through hashing a stylesheet correctly, self-hosting or pinning it so the digest is stable, and then confronting the hard boundary: what SRI does and does not cover once fonts enter the picture. It covers the conceptual model, five implementation steps with verifiable output, a configuration reference, troubleshooting for real font-CSS mismatches, and the precise security limit you are left with.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation: Where the Integrity Boundary Falls
Permalink to "Conceptual Foundation: Where the Integrity Boundary Falls"The W3C Subresource Integrity specification (§3.2, Elements and attributes) defines the integrity attribute for exactly two element types: <script> and <link> elements whose relationship is a subresource relationship — chiefly rel="stylesheet" and rel="preload". The browser fetches the referenced resource, computes a digest over the decoded response body, and compares it to the declared token before the resource is applied. For a stylesheet, “applied” means parsed into the CSSOM and used for style resolution.
That is the entire scope of the guarantee: the bytes of the CSS file itself. The specification has no concept of transitively verifying resources that the CSS refers to. When the CSS parser encounters an @font-face block, the src: url(...) descriptor names a font file that the browser fetches as a brand-new subresource on demand — typically only when a glyph from that family is actually rendered. That font fetch is an ordinary request. There is no integrity descriptor in the @font-face at-rule (the CSS Fonts specification defines src, font-weight, unicode-range, and related descriptors, but nothing cryptographic), so nothing compares the delivered font bytes to any expected value.
The result is a two-layer trust picture. The stylesheet link is a hard, hash-enforced boundary. Everything the stylesheet then pulls in through url() — fonts, but also background images and @import-ed sub-stylesheets — sits on the far side of that boundary, unverified. The diagram below makes the split explicit.
The practical consequence is that “I put SRI on my stylesheet” is not the same claim as “my fonts are integrity-protected.” The following steps get the stylesheet boundary correct first, then address the font layer with the only tools that actually apply there.
Step 1 — Self-Host or Pin the Stylesheet
Permalink to "Step 1 — Self-Host or Pin the Stylesheet"SRI verifies a fixed digest, so the CSS URL must return the same bytes on every request. Two hosting models satisfy that; a third — the default Google Fonts CSS endpoint — does not.
The Google Fonts CSS URL (https://fonts.googleapis.com/css2?family=...) returns a different payload depending on the requesting User-Agent. Chrome receives woff2 @font-face blocks with one set of unicode-range subsets; older engines receive different formats and ranges. Because the bytes differ per browser, a single sha384- token computed from one browser’s response will fail for visitors on another engine. This byte-instability is the core reason to self-host, covered in depth in Adding Integrity to Google Fonts and CSS.
The robust choice is to download the CSS once and serve it yourself:
# Fetch the CSS as a real browser would, then save the stable artifact
curl -sL \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" \
"https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" \
-o src/fonts/inter.css
Expected output — a local inter.css whose bytes no longer change between deploys:
$ wc -c src/fonts/inter.css
2417 src/fonts/inter.css
You now own a fixed artifact. Rewrite the src: url(...) targets inside it to point at self-hosted font files (Step 5) so the whole chain lives on origins you control.
Step 2 — Compute the SHA-384 Digest of the CSS
Permalink to "Step 2 — Compute the SHA-384 Digest of the CSS"Hash the exact bytes the server will deliver — after any minification or autoprefixer pass, not the source. SHA-384 is the recommended default algorithm.
# Produce the sha384- token for the stylesheet
openssl dgst -sha384 -binary dist/fonts/inter.css | openssl base64 -A
Expected output — a 64-character base64 string you prefix with sha384-:
h9d0e5rZ0b0m2Q6xk3sVQe0y8u2pQ3n7Xh4l1oCq6b8sJt3vN0aW9cR2yE7fLpH
The -binary flag emits raw digest bytes rather than hex; -A suppresses line wrapping. Omitting either produces a value the browser rejects. If your build already emits an SRI manifest, read the token from there instead of recomputing by hand.
Step 3 — Add integrity and crossorigin to the Link Element
Permalink to "Step 3 — Add integrity and crossorigin to the Link Element" Attach the token to the <link rel="stylesheet">. For any cross-origin stylesheet the crossorigin="anonymous" attribute is mandatory — without it the browser issues a no-CORS opaque fetch it cannot read for hashing, and blocks the resource.
<!-- Self-hosted stylesheet on an assets subdomain (cross-origin) -->
<link
rel="stylesheet"
href="https://assets.example.com/fonts/inter.7f3a91.css"
integrity="sha384-h9d0e5rZ0b0m2Q6xk3sVQe0y8u2pQ3n7Xh4l1oCq6b8sJt3vN0aW9cR2yE7fLpH"
crossorigin="anonymous"
/>
Expected result — the stylesheet loads, styles apply, and the Network panel shows a 200 for the CSS with the integrity attribute honoured. If the stylesheet is served from the same origin as the document you may omit crossorigin, because same-origin responses are already readable; keep it for anything on a different host, including a separate subdomain.
Step 4 — Verify Enforcement in the Browser
Permalink to "Step 4 — Verify Enforcement in the Browser"Confirm the gate actually fires. Load the page in Chrome or Firefox with DevTools open. A correct hash produces no error and a normal styled render. A wrong hash produces a precise console message:
Failed to find a valid digest in the 'integrity' attribute for resource
'https://assets.example.com/fonts/inter.7f3a91.css' with computed SHA-384
integrity 'sha384-<actual>'. The resource has been blocked.
To prove the enforcement is real, temporarily flip one character of the token and reload — the stylesheet must be blocked and the page must render unstyled. Restore the correct token afterward. You can also re-derive the hash from the live URL and diff it against the HTML:
curl -sL https://assets.example.com/fonts/inter.7f3a91.css \
| openssl dgst -sha384 -binary | openssl base64 -A
Expected output — a string identical, character for character, to the token in the integrity attribute.
Step 5 — Handle the Font Files (the Boundary)
Permalink to "Step 5 — Handle the Font Files (the Boundary)"This is the step teams skip. The stylesheet is now hash-protected, but every font it names through @font-face is still an unverified fetch. There is no integrity descriptor to add inside the @font-face rule — the CSS specification does not define one. The only defences that apply on this side of the boundary are hosting-based.
Self-host the font files and content-address them so their URL changes whenever their bytes change:
/* dist/fonts/inter.7f3a91.css — the hash-verified stylesheet */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
/* Self-hosted, content-addressed — no integrity attribute exists here */
src: url("/fonts/inter-400.9c2ab4.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
Then constrain where fonts may load from with a Content-Security-Policy font-src directive, which is enforceable at the policy layer even though per-font hashing is not:
Content-Security-Policy: font-src 'self'; style-src 'self'
Expected result — the browser refuses to fetch a font from any origin other than your own, closing the door on a @font-face src that a tampered stylesheet might try to point at an attacker origin. Because the stylesheet itself is already hash-locked in Step 3, an attacker cannot alter these url() targets without breaking the CSS digest. The combination — hash-locked CSS plus a restrictive font-src — is the closest practical equivalent to integrity for fonts. Wiring font-src and style-src together with the rest of your policy is covered in Configuring Content Security Policy with SRI.
Configuration Reference
Permalink to "Configuration Reference"| Option | Valid values | Notes and security implication |
|---|---|---|
integrity on <link rel="stylesheet"> |
sha384-… (default), sha256-…, sha512-… |
Verifies the CSS bytes only; SHA-384 is the recommended default |
crossorigin on stylesheet |
anonymous, use-credentials, omitted |
Required as anonymous for cross-origin CSS; omit only for genuine same-origin files; use-credentials leaks cookies into the fetch |
integrity on @font-face src |
not available | No integrity descriptor exists in CSS; font bytes cannot be hash-verified |
<link rel="preload" as="style"> |
integrity, crossorigin |
May carry its own hash; crossorigin mode must match the stylesheet or the preload is dropped and the file is fetched twice |
CSP style-src |
'self', host allow-list, 'nonce-…' |
Restricts which origins may serve stylesheets; complements SRI |
CSP font-src |
'self', host allow-list |
The only policy-layer control over where fonts load from; the substitute for per-font integrity |
| Google Fonts CSS endpoint | fonts.googleapis.com/css2?… |
Byte-unstable per User-Agent; unsuitable for a fixed SRI hash — self-host instead |
require-sri-for |
style, script, script style |
Forces every stylesheet to carry an integrity attribute; does not extend to @font-face targets |
Integration with Adjacent Tooling
Permalink to "Integration with Adjacent Tooling"Stylesheet SRI sits alongside the same build and enforcement machinery used for scripts. Its digest is produced by the hash step and enforced by the browser gate:
- Adding Integrity to Google Fonts and CSS — the focused reference for the Google Fonts case specifically: pinned versions, preloading, multiple stylesheets, and the byte-instability gotcha in copy-pasteable form.
- Browser Enforcement & Security Boundaries — the fetch lifecycle that validates the stylesheet digest, including how
require-sri-for styleforces every<link rel="stylesheet">to carry a hash.
For build pipelines that emit stylesheet hashes automatically, the vite-plugin-subresource-integrity and webpack-subresource-integrity plugins inject integrity on emitted <link> tags the same way they do for scripts, so a self-hosted font stylesheet is covered without a manual hashing step.
Troubleshooting
Permalink to "Troubleshooting"net::ERR_SRI_FAILED on the Google Fonts CSS URL for some users only
The integrity token was computed from one browser’s response, but fonts.googleapis.com/css2 served a different payload to a visitor on another engine (different woff2/unicode-range blocks). The digest is valid for the browser you hashed on and invalid for everyone else. Fix: stop pointing SRI at the live Google endpoint. Self-host the CSS per Step 1 so every visitor receives byte-identical content and one hash validates universally.
Stylesheet blocked, page renders unstyled, console shows a digest mismatch
The hash was computed from the source CSS but the server delivers a minified or autoprefixed variant, so the bytes differ. Re-run the Step 2 command against the exact deployed artifact (curl the live URL and pipe to openssl), and copy that value into the integrity attribute.
crossorigin absent — cross-origin stylesheet blocked with an opaque response
The <link> points at a different origin (often an assets. subdomain or CDN) but has no crossorigin attribute, so the browser made a no-CORS fetch it cannot read for hashing. Add crossorigin="anonymous" and confirm the response carries Access-Control-Allow-Origin.
Fonts render but a tampered font file went undetected
Working as designed, and the point of the security boundary: the @font-face url() target is not integrity-checked. If a font file were swapped at its origin, the stylesheet hash would still pass because the CSS bytes are unchanged. Mitigate by self-hosting the fonts under content-addressed filenames and restricting font-src to 'self' (Step 5); there is no per-font hash to add.
Preloaded stylesheet is fetched twice
A <link rel="preload" as="style"> and the matching <link rel="stylesheet"> declare different crossorigin modes (one omitted, one anonymous). The browser cannot reuse the preloaded response, discards it, and re-fetches. Make the crossorigin value identical on both, and put the same integrity token on both.
@import inside a hashed stylesheet pulls an unverified sub-stylesheet
An @import url(...) in the hash-protected CSS loads a second stylesheet that carries no integrity check of its own — the same boundary as fonts. Inline the imported rules into the hashed file, or serve the sub-stylesheet from 'self' and restrict style-src accordingly.
Security Boundary
Permalink to "Security Boundary"Stylesheet SRI guarantees that the bytes of the linked CSS file match the hash computed at build time. It explicitly does not:
- Verify any
@font-faceurl()target. Font files are separate subresources with no integrity mechanism in CSS. A hash on the stylesheet says nothing about the font bytes the browser later fetches and renders. - Verify other
url()resources the CSS references — background images, cursors, or@import-ed sub-stylesheets are all fetched outside the SRI boundary and are unverified. - Stabilise a byte-unstable source. Pointing an
integrityattribute at the live Google Fonts CSS endpoint cannot work, because that endpoint returns different bytes per User-Agent; SRI requires a fixed payload, which only self-hosting or a pinned immutable URL provides. - Replace transport and policy controls for fonts. Because per-font hashing does not exist, font protection reduces to TLS, self-hosting under content-addressed paths, and a CSP
font-srcallow-list — all outside SRI itself. - Protect against a compromised build that emits a malicious-but-consistent stylesheet. SRI hashes whatever the pipeline produced; build-provenance controls, not SRI, address that.
Frequently asked questions
Permalink to "Frequently asked questions"Does the stylesheet's integrity hash also protect the fonts it loads?
No. The integrity attribute on a <link rel="stylesheet"> covers only the CSS file’s bytes. Fonts referenced by @font-face url() are separate subresources fetched later — usually only when a glyph is rendered — and CSS has no integrity mechanism for them. A tampered font file is fetched and applied without any hash check. The stylesheet hash indirectly protects the reference (an attacker cannot rewrite the url() without breaking the CSS digest), but not the font bytes at that URL.
Why does the Google Fonts CSS hash keep changing?
The fonts.googleapis.com/css2 endpoint returns different bytes depending on the requesting browser’s User-Agent, because it serves format-specific and unicode-range-specific @font-face rules tailored to each engine. Two browsers receive two different CSS payloads with two different SHA-384 digests, so one pinned hash cannot validate for every visitor. Self-host the CSS to obtain a stable, universal hash.
Can I add an integrity attribute to a @font-face src url()?
No. The @font-face at-rule has no integrity descriptor in the CSS specification. SRI attaches only to HTML elements that accept an integrity attribute — <script> and <link>. To protect font bytes, self-host them under content-addressed filenames, serve them over TLS from an origin you control, and restrict font-src in your Content-Security-Policy.
Do I still need crossorigin on a self-hosted same-origin stylesheet?
For a genuinely same-origin stylesheet, crossorigin is not required — same-origin responses are already readable for hashing. Add crossorigin="anonymous" whenever the stylesheet is served from a different origin, including a separate assets subdomain or a CDN; otherwise the browser issues an opaque no-CORS fetch and blocks the resource.
Does preload interact with SRI on stylesheets?
Yes. A <link rel="preload" as="style"> may carry its own integrity attribute, and the browser reuses the preloaded, already-verified response for the matching <link rel="stylesheet">. The crossorigin mode on the preload must match the stylesheet’s, or the preload is discarded and the resource is fetched twice, wasting the round trip.
Is stylesheet SRI enough for PCI DSS script/style integrity requirements?
Stylesheet SRI, combined with require-sri-for style, satisfies the integrity-verification element for stylesheets. It does not cover fonts, images, or imported sub-stylesheets, which have no browser-native integrity mechanism — those are handled through self-hosting and CSP font-src/img-src restrictions rather than hashes.
Related
Permalink to "Related"- Adding Integrity to Google Fonts and CSS — the focused how-to for pinning, preloading, and the Google Fonts byte-instability gotcha
- Browser Enforcement & Security Boundaries — the fetch-time gate that validates stylesheet digests and the
require-sri-for styledirective - Configuring Content Security Policy with SRI — pairing
font-srcandstyle-srcwith SRI for defence in depth - Core SRI Fundamentals & Browser Security Boundaries — the broader model of what SRI protects and where its boundaries fall