Adding Integrity to Google Fonts and CSS

Permalink to "Adding Integrity to Google Fonts and CSS"

Part of Applying SRI to Stylesheets & Web Fonts, this page is the copy-pasteable reference for putting a valid integrity attribute on a <link rel="stylesheet"> — for both self-hosted CSS and Google Fonts — and for the traps that make stylesheet SRI fail in ways script SRI never does.

Quick Reference

Permalink to "Quick Reference"
Property Value
Element <link rel="stylesheet">
Attribute integrity
Algorithm identifier sha384 (default), sha256, sha512
Token format sha384-<base64 digest>
Required companion attribute crossorigin="anonymous" (cross-origin CSS)
CORS response header needed Access-Control-Allow-Origin
Preload support <link rel="preload" as="style" integrity="…">
CSP enforcement require-sri-for style
Browser support All major engines (Chromium, Firefox, Safari)
Covers font files? No — @font-face url() is unverified

Use SHA-384 unless a specific constraint forces another algorithm. The stylesheet URL must return identical bytes on every request, or the hash cannot validate.

How Stylesheet Integrity Works

Permalink to "How Stylesheet Integrity Works"

The browser fetches the CSS, decodes any Content-Encoding, computes the digest over the decoded bytes, and compares it to the integrity token before parsing the stylesheet into the CSSOM. A match applies the styles; a mismatch blocks the stylesheet and logs net::ERR_SRI_FAILED. The guarantee is scoped to the CSS bytes only — fonts and images the CSS references through url() are fetched separately and are not covered, a boundary detailed in the parent page and enforced by the same fetch-time gate described in Browser Enforcement & Security Boundaries.

Two conditions must both hold for the check to even run. First, the response body must be readable: for a cross-origin stylesheet that means a CORS-eligible fetch (crossorigin="anonymous") answered with an Access-Control-Allow-Origin header. Second, the bytes must be deterministic — the same URL must return the same payload on every request, or the fixed token in the HTML cannot match what an arbitrary visitor receives. Script SRI usually satisfies both trivially because bundles are content-addressed and static. Stylesheet SRI for fonts breaks precisely because the popular delivery model — the Google Fonts CSS endpoint — violates the second condition, returning per-browser payloads. Everything below is about restoring determinism and keeping the response readable.

Google Fonts CSS Byte-Instability Diagram showing the Google Fonts CSS endpoint returning different bytes to Chrome and Firefox, so their SHA-384 hashes differ and a single pinned integrity token cannot validate for both; self-hosting collapses them to one stable hash. fonts.googleapis /css2 endpoint Chrome bytes sha384-AAA… Firefox bytes sha384-BBB… One pinned token matches at most one — others blocked fix: self-host → single stable hash

Canonical Example

Permalink to "Canonical Example"

The following is a complete, production-ready self-hosted stylesheet reference with a SHA-384 integrity attribute. This is the pattern to reach for by default.

<link
  rel="stylesheet"
  href="https://assets.example.com/fonts/inter.7f3a91.css"
  integrity="sha384-h9d0e5rZ0b0m2Q6xk3sVQe0y8u2pQ3n7Xh4l1oCq6b8sJt3vN0aW9cR2yE7fLpH"
  crossorigin="anonymous"
/>

Generate the sha384-… token from the exact bytes the server delivers:

# Hash the deployed CSS artifact, not the source
openssl dgst -sha384 -binary dist/fonts/inter.css | openssl base64 -A
# → h9d0e5rZ0b0m2Q6xk3sVQe0y8u2pQ3n7Xh4l1oCq6b8sJt3vN0aW9cR2yE7fLpH

The -binary flag emits raw digest bytes; -A suppresses newline wrapping. Omitting either yields a value the browser rejects.

Variant Examples

Permalink to "Variant Examples"

Pinned Google Fonts CSS version

Permalink to "Pinned Google Fonts CSS version"

If you must reference Google’s CSS directly rather than self-hosting, pin a fixed User-Agent fetch and hash that payload — accepting that the token is only valid for browsers receiving the same bytes. This is fragile; self-hosting is strongly preferred.

# Capture one specific representation and hash it
curl -sL \
  -H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
  "https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" \
  -o inter-pinned.css
openssl dgst -sha384 -binary inter-pinned.css | openssl base64 -A
<!-- Fragile: only validates for User-Agents that receive identical bytes -->
<link
  rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
  integrity="sha384-Kx9pL2mQ7rT4vW1yB6nE8sH0jC5dF3gA9uZ2xR7wN4tM1oP6qS8bV0cX3yG5hJ2"
  crossorigin="anonymous"
/>

Preloading a hashed stylesheet

Permalink to "Preloading a hashed stylesheet"

Preload the stylesheet to start the fetch earlier while keeping the hash check. The crossorigin and integrity values must match on both tags, or the preload is discarded and the file is fetched twice.

<link
  rel="preload"
  as="style"
  href="https://assets.example.com/fonts/inter.7f3a91.css"
  integrity="sha384-h9d0e5rZ0b0m2Q6xk3sVQe0y8u2pQ3n7Xh4l1oCq6b8sJt3vN0aW9cR2yE7fLpH"
  crossorigin="anonymous"
/>
<link
  rel="stylesheet"
  href="https://assets.example.com/fonts/inter.7f3a91.css"
  integrity="sha384-h9d0e5rZ0b0m2Q6xk3sVQe0y8u2pQ3n7Xh4l1oCq6b8sJt3vN0aW9cR2yE7fLpH"
  crossorigin="anonymous"
/>

Multiple stylesheets

Permalink to "Multiple stylesheets"

Each <link> carries its own token; there is no shared attribute. Compute one digest per file.

<link rel="stylesheet" href="/css/base.a1b2c3.css"
  integrity="sha384-Tx0aQ9wE2rY7uI4oP1sD6fG3hJ8kL5mN0bV7cX2zA9qW4eR1tY6uI3oP8sD5fG2"
  crossorigin="anonymous" />
<link rel="stylesheet" href="/css/theme.d4e5f6.css"
  integrity="sha384-Ry1bS0xF3tZ8vJ5pQ2eE7gH4iK9lM6nO1cW8dY3aB0rX5fS2uZ7vJ4qR9tY6uI3"
  crossorigin="anonymous" />

SHA-512 for high-assurance pages

Permalink to "SHA-512 for high-assurance pages"

SHA-384 is the default. Use SHA-512 only when a higher-assurance regime calls for it — the token is longer but the tag is otherwise identical. Show SHA-256 only where a legacy constraint forces the shorter digest.

openssl dgst -sha512 -binary dist/fonts/inter.css | openssl base64 -A
<link
  rel="stylesheet"
  href="https://assets.example.com/fonts/inter.7f3a91.css"
  integrity="sha512-9Ck0m1sX3hV6dQ2yB8nE4tR7uP0jC5dF3gA9uZ2xR7wN4tM1oP6qS8bV0cX3yG5hJ2kL5mN0bV7cX2zA9qW4eR1tY6uI3oP8sD5fG2hJ8kL5mN0bV7cX2zA9qW4eR1tY6"
  crossorigin="anonymous"
/>

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin="anonymous" silently blocks the stylesheet. On a cross-origin <link rel="stylesheet"> with an integrity attribute but no crossorigin, the browser issues a no-CORS opaque fetch whose bytes it cannot read. It cannot hash an unreadable body, so it blocks the resource — not because the hash failed, but because the body was never available. This is the single most common stylesheet-SRI bug; always pair integrity with crossorigin="anonymous" for cross-origin CSS.

  • The Google Fonts CSS is byte-unstable per browser. fonts.googleapis.com/css2 serves different @font-face rules (formats, unicode-range subsets) depending on the requesting User-Agent. A token hashed from Chrome’s response fails for Firefox or Safari visitors. Do not pin SRI to the live endpoint — self-host the CSS so every visitor receives byte-identical content and one hash validates universally.

  • @font-face font files are never integrity-checked. The stylesheet hash covers the CSS bytes only. Font files named in src: url(...) are separate subresources with no integrity descriptor in CSS. Self-host and content-address the fonts and restrict font-src in your CSP; there is no per-font hash to add.

  • Hash the served artifact, not the source. Minification and autoprefixer change the bytes. Compute the digest after the full build, or by curl-ing the live URL, otherwise every deploy mismatches.

  • crossorigin must match between preload and stylesheet. A mismatch (one omitted, one anonymous) makes the browser discard the preload and fetch the CSS a second time, silently negating the optimization.

  • Compression is transparent, mutation is not. The browser hashes the decoded body, so Content-Encoding: gzip or br from the CDN is fine. What breaks the token is CDN-side CSS minification, URL rewriting, or comment stripping applied at the edge — any transformation that changes the decoded bytes. Disable payload-mutating rules for SRI-protected stylesheet paths.

  • @import inside a hashed stylesheet escapes the check. An @import url(...) pulls a second stylesheet that carries no integrity check of its own. Inline the imported rules into the hashed file, or serve the sub-stylesheet from 'self' under a style-src restriction.

Verification Steps

Permalink to "Verification Steps"

1. Confirm both attributes are present

Permalink to "1. Confirm both attributes are present"
grep -E 'rel="stylesheet"' dist/index.html | grep -E 'integrity=|crossorigin='

Expected — every cross-origin stylesheet line shows both integrity="sha384-…" and crossorigin="anonymous".

2. Re-derive the hash from the live CSS

Permalink to "2. Re-derive the hash from the live CSS"
curl -sL https://assets.example.com/fonts/inter.7f3a91.css \
  | openssl dgst -sha384 -binary | openssl base64 -A

The output must match the token in the HTML character for character.

3. Confirm clean enforcement in the browser

Permalink to "3. Confirm clean enforcement in the browser"

Load the page in Chrome or Firefox with DevTools open. A failure produces:

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.

No such message, and a styled render, means the hash is correct. Flip one character of the token and reload to prove the gate blocks a mismatch, then restore it.

4. Gate CI on stylesheet hash drift

Permalink to "4. Gate CI on stylesheet hash drift"

Add a pipeline step that re-derives every stylesheet token from the built files and diffs it against the emitted HTML, failing the build on any divergence so a stale hash never ships:

# GitHub Actions
- name: Verify stylesheet SRI tokens
  run: |
    grep -oP 'href="\K[^"]+\.css' dist/index.html | while read -r css; do
      declared=$(grep -oP "href=\"${css//\//\\/}\"[^>]*integrity=\"\K[^\"]+" dist/index.html)
      computed="sha384-$(openssl dgst -sha384 -binary "dist/${css}" | openssl base64 -A)"
      [ "$declared" = "$computed" ] || { echo "Mismatch: $css"; exit 1; }
    done
    echo "All stylesheet hashes verified."

The step exits non-zero on drift, blocking promotion before a broken token reaches a CDN edge.

Permalink to "Related"
Applying SRI to Stylesheets & Web Fonts Core SRI Fundamentals & Browse…