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.

Stylesheet SRI Coverage Boundary A diagram showing the HTML link element with an integrity attribute fetching a CSS file whose bytes are verified against the SHA-384 hash and allowed, while the @font-face url() font files it references are fetched as separate unverified subresources outside the SRI coverage boundary. SRI coverage — hash enforced <link rel= stylesheet> integrity="" styles.css SHA-384 match Allow ✓ @font-face src: url(font.woff2) no integrity check Outside SRI — unverified font.woff2 fetched, applied background url() also unverified

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.


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 style forces 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-face url() 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 integrity attribute 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-src allow-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.


Permalink to "Related"

Articles in This Topic

Adding Integrity to Google Fonts and CSS
Back to Core SRI Fundamentals & Browser Security Boundaries