Why Font Files Cannot Carry an integrity Attribute

Permalink to "Why Font Files Cannot Carry an integrity Attribute"

Part of Applying SRI to Stylesheets & Web Fonts, this page explains precisely why the integrity attribute has no place to live on a web font, what the specification does and does not say about rel="preload", and which controls actually stand in for it.

Quick Reference

Permalink to "Quick Reference"
Surface Accepts integrity? Required companion Notes
<script src> Yes crossorigin="anonymous" when cross-origin The original SRI target
<link rel="stylesheet"> Yes crossorigin="anonymous" when cross-origin Covers the CSS text, not its subresources
<link rel="modulepreload"> Yes crossorigin="anonymous" Checked on the module fetch
<link rel="preload" as="font"> Yes, per HTML grammar crossorigin="anonymous" (mandatory for fonts) Validates the preload fetch only
@font-face { src: url(…) } No No attribute surface exists in CSS
<img>, <video>, <iframe> No Out of scope for SRI entirely

Algorithm to prefer everywhere integrity is accepted: SHA-384. Browser support for integrity on <script> and <link rel="stylesheet"> is universal in current Chromium, Firefox and Safari; support on rel="preload" is newer and should be treated as an optimisation, not a gate.

The mental model

Permalink to "The mental model"

Subresource Integrity is not a property of a file. It is a property of a fetch, and the only way to attach it declaratively is through an attribute on the element that initiates that fetch. The browser reads integrity, parses it into integrity metadata, hands that metadata to the fetch algorithm alongside the URL, and the fetch layer refuses to deliver a response whose digest does not match. Every part of that chain depends on there being an element with an attribute in the first place.

A web font never gets one. When you write a @font-face rule, you are not declaring a resource to load — you are declaring a candidate face in the font database. Nothing is fetched at parse time. Later, when layout resolves a font-family and decides that this particular face is needed for text that is actually on screen, the CSS font loading machinery issues a request for the URL in the src descriptor. That request is created by the style engine from a CSS token, not by an element from a markup attribute, and there is nowhere in CSS syntax to hang integrity metadata on it. The chain that protects the stylesheet simply stops at the stylesheet boundary.

Where the integrity chain stops A link element with an integrity attribute causes the browser to hash and compare the CSS bytes, but the at-font-face rule inside that CSS produces a font request with no element and no attribute, so the font response is never compared against a digest. fetch chain for a font referenced from a stylesheet <link rel=stylesheet> integrity="sha384-…" CSS response digest compared @font-face rule src: url(font.woff2) font request no element attribute present metadata parsed hash enforced on the CSS bytes CSS text only no attributes here no digest comparison the guarantee ends where the element ends

The practical consequence is worth stating plainly: putting integrity on the <link> that loads your font stylesheet is genuinely valuable, because it pins the URL the font will be fetched from. An attacker who can rewrite that CSS in transit cannot repoint src at their own host without breaking the stylesheet digest. What it does not do is say anything about the bytes that come back from the font URL. Those two guarantees are often conflated, and the confusion is exactly why teams believe they have covered fonts when they have covered only the file that names them.

What the specification actually says

Permalink to "What the specification actually says"

The scope is narrow and explicit. Subresource Integrity defines integrity for script and link elements, and the HTML Standard adds the constraint that on a link the attribute is meaningful only when rel contains stylesheet, preload, or modulepreload. Anywhere else — on an <img>, on an <iframe>, on a <video> <source> — the attribute is parsed as an unknown attribute and ignored. The Fetch standard is where the actual comparison happens: a request carries integrity metadata, and if that metadata is non-empty the response body is buffered, digested, and compared before it is delivered. The declarative attribute is one of several ways to populate that field; the integrity option on fetch() and the integrity key in an import map are the others.

Which fetch surfaces accept integrity A six-row matrix listing script, stylesheet link, modulepreload link, font preload link, the at-font-face src descriptor and media elements, showing for each whether an integrity attribute exists and whether the delivered bytes are enforced against a digest. fetch surface integrity attribute? bytes enforced? <script src> yes yes <link rel="stylesheet"> yes yes, the CSS text <link rel="modulepreload"> yes yes <link rel="preload" as="font"> yes, in the grammar preload fetch only @font-face src: url(…) no such descriptor no <img>, <video>, <iframe> no no

Two clarifications follow from that table and both matter. First, a stylesheet’s integrity covers the stylesheet’s own bytes and nothing it references — not fonts, not background images, not @imported sheets. This is the same boundary described in Adding Integrity to Google Fonts and CSS, and it is why a hosted font service that serves a CSS file whose contents vary by user agent is awkward to hash at all. Second, the CSP directive require-sri-for only ever accepted the tokens script and style; there was never a font token, and the directive itself never advanced beyond experimental status in shipping engines. If your threat model is currently written around it, read Combining require-sri-for with CSP before you plan any font coverage on top of it.

Why preload does not close the gap

Permalink to "Why preload does not close the gap"

The one place a font URL does appear on an element is a preload hint, and because rel="preload" is on the allowed list the grammar accepts integrity there. It is tempting to read that as a fix. It is not, for a structural reason.

A preload is a hint that warms a cache. The browser performs a fetch, and if you supplied integrity metadata that fetch is validated. The bytes then sit in the preload cache waiting to be claimed. When layout later needs the face, the CSS font loading machinery issues its own request for the same URL — a separate request, created with no integrity metadata, that may or may not be satisfied from the preload entry. Preload entries are matched on URL together with the request’s mode and credentials, and they can be dropped: a mismatched crossorigin, a different as value, or simple eviction all cause a miss. On a miss the browser goes to the network and renders whatever comes back, with no digest comparison anywhere in the path.

Preload fetch versus the later font fetch The browser sends a preload request carrying integrity metadata and hashes the returned bytes, but the subsequent at-font-face request is issued without integrity metadata, so when it goes to the network the response is accepted without any hash comparison. browser fetch layer origin or CDN server 1. preload GET, integrity sent 2. font bytes returned 3. digest compared and entry parked in cache 4. @font-face GET, no metadata 5. bytes returned again 6. nothing to compare against on this path

Steps 4 through 6 are the whole argument. A control that applies on the happy path and silently disappears on a cache miss is a performance feature that happens to do a check, not a security boundary. Add integrity to a font preload if you like — it costs nothing and it does catch a corrupted preload — but do not write it into a control matrix as the thing that protects your fonts.

Canonical example: a hardened self-hosted font pipeline

Permalink to "Canonical example: a hardened self-hosted font pipeline"

This is the configuration that replaces the missing attribute. The stylesheet is hashed and pinned; the font sits behind a content-addressed, immutable URL on your own origin; and CSP refuses font bytes from anywhere else.

/* assets/fonts.css — the filename below is emitted by the build with a content hash */
@font-face {
  font-family: "Inter";
  src: url("/assets/fonts/inter-var.a3f19c4e.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}
<link rel="stylesheet"
      href="/assets/fonts.9d4c1b77.css"
      integrity="sha384-oWF20oVhBFJQKUQx2k9NkEC0C6wPgaP0oGSFZJDb9Rymjh6Sl+KXtNq/2u0KskKA"
      crossorigin="anonymous">
# Fonts are content-addressed, so they can be cached forever and never revalidated.
location ^~ /assets/fonts/ {
    types { font/woff2 woff2; }
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    add_header Access-Control-Allow-Origin "https://www.example.com" always;
}

# Only this origin may supply font bytes to the page.
add_header Content-Security-Policy "default-src 'self'; style-src 'self'; font-src 'self'" always;

Three separate properties are being asserted here and it is worth naming them individually. The integrity on the stylesheet asserts that the src URL has not been rewritten. The content hash in inter-var.a3f19c4e.woff2 asserts that the URL and the bytes were bound together at build time, so a cache serving that URL cannot be serving a different build. The font-src 'self' directive asserts that no rewritten rule anywhere on the page — injected by an extension, a tag manager, or a DOM XSS — can pull a face from a host you did not approve. None of them is a digest check on the wire, and together they close most of what the digest check would have closed.

The Access-Control-Allow-Origin header is not optional even when the font is on your own domain but a different hostname: font fetches are always made in CORS mode, which is also why the preload variant below must carry crossorigin. The mechanics of that mode, and what happens when it is missing, are covered in How CORS and crossorigin Affect SRI.

Variants

Permalink to "Variants"

Preload the font, with integrity, as a bonus

Permalink to "Preload the font, with integrity, as a bonus"
<link rel="preload"
      as="font"
      type="font/woff2"
      href="/assets/fonts/inter-var.a3f19c4e.woff2"
      integrity="sha384-FZ2mQdHCwISV+Fy2gpZ3rpRVR0O4xz0NTCqABxWINlC3pATT5yc7rWluP+CAvPvA"
      crossorigin="anonymous">

crossorigin="anonymous" is mandatory here regardless of origin. Without it the preload is made in a different mode than the eventual font fetch, the entry never matches, and you get two downloads instead of one — a performance regression that also removes whatever value the integrity was contributing. Generate the digest with the same procedure you use for scripts, described in How to Calculate SHA-256 vs SHA-384 for SRI.

Verify the bytes yourself and install a FontFace

Permalink to "Verify the bytes yourself and install a FontFace"

When a page genuinely needs a digest guarantee on the font — a payment form, a signing surface, anything where rendered text is the security-relevant output — bypass url() entirely. Fetch the file with the Fetch API’s integrity option, which populates the same integrity metadata field the attribute would have, then hand the verified buffer to the FontFace constructor:

// Fetch rejects with a TypeError if the digest does not match.
async function installVerifiedFont(family, url, integrity, descriptors = {}) {
  const response = await fetch(url, { integrity, mode: 'cors', credentials: 'omit' });
  if (!response.ok) {
    throw new Error(`font fetch failed with status ${response.status}`);
  }

  const bytes = await response.arrayBuffer();
  const face = new FontFace(family, bytes, descriptors);
  await face.load();
  document.fonts.add(face);
  return face;
}

installVerifiedFont(
  'Inter',
  '/assets/fonts/inter-var.a3f19c4e.woff2',
  'sha384-szshFOLurqlxaDGwUphrbg6HxtMBEQ73dI1J34/i5lX5Fw3dMchS+3bfeRHm+CHZ',
  { weight: '100 900', display: 'swap' }
).catch((err) => {
  console.error('font rejected, falling back to system stack', err);
});

The FontFace constructor accepts binary font data as its source, so no second network request happens and there is no URL for anything to intercept. Keep a system font stack in your font-family list so the catch branch degrades to readable text rather than blank glyphs. Applying this pattern across a real font set, including the build-time hash generation, is the subject of SRI for Self-Hosted Web Fonts.

Audit the digest independently with SubtleCrypto

Permalink to "Audit the digest independently with SubtleCrypto"

If you want to log or report what was actually served rather than only fail closed, digest the bytes yourself. A SHA-384 digest is 48 bytes, small enough to spread safely into String.fromCharCode:

async function sriDigest(buffer, algorithm = 'SHA-384') {
  const digest = await crypto.subtle.digest(algorithm, buffer);
  const b64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
  return `${algorithm.toLowerCase().replace('-', '')}-${b64}`;
}

crypto.subtle is only exposed in secure contexts, so this runs on HTTPS and on localhost and nowhere else. Feed the result into your violation telemetry alongside CSP reports rather than treating it as an enforcement point on its own.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • An integrity line inside @font-face is silently discarded. CSS drops descriptors it does not recognise without a console message, so the rule still parses, the font still loads, and code review is the only thing standing between you and a control that was never real. Grep your stylesheets for integrity outside of markup and delete every hit.

  • Omitting crossorigin on any tag that carries integrity disables the check. For a cross-origin script or stylesheet the response is opaque and the browser cannot read the bytes to hash them, so the resource is blocked outright. For a font preload the failure is quieter: the entry simply never matches the real font request. Every example on this page carries crossorigin="anonymous" for that reason.

  • require-sri-for has no font token and never did. Its grammar accepted script and style only, and the directive was withdrawn from the engines that briefly shipped it behind a flag. There is no CSP directive that mandates integrity metadata on font fetches.

  • A content hash in the filename is a build guarantee, not a browser check. The browser does not parse inter-var.a3f19c4e.woff2 and verify anything. The hash buys you cache correctness and makes a silent swap at the same URL impossible for anyone who cannot also change your HTML — which is exactly the attacker the stylesheet integrity is there to stop.

  • Hosted font services can change bytes under a stable URL. Providers reserve the right to reissue files, and some serve different font formats per user agent from the same CSS URL. That is legitimate behaviour that makes both stylesheet hashing and any font-level pinning fragile, and it is the strongest practical argument for copying the files into your own build output.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the font is served the way you think it is

Permalink to "1. Confirm the font is served the way you think it is"
curl -sI https://www.example.com/assets/fonts/inter-var.a3f19c4e.woff2 \
  | grep -iE 'content-type|cache-control|access-control-allow-origin'

Expected output:

content-type: font/woff2
cache-control: public, max-age=31536000, immutable
access-control-allow-origin: https://www.example.com

A missing access-control-allow-origin means the font will fail to load once anything requests it in CORS mode, which is every font request.

2. Confirm the deployed bytes match the hash you pinned

Permalink to "2. Confirm the deployed bytes match the hash you pinned"
curl -s https://www.example.com/assets/fonts/inter-var.a3f19c4e.woff2 \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

The 64-character base64 string this prints must equal the part after sha384- in your preload tag or your installVerifiedFont call. Any difference means the deployed file is not the file you hashed at build time — re-run the build before you go looking for a network attacker.

3. Confirm CSP rejects a font from an unapproved origin

Permalink to "3. Confirm CSP rejects a font from an unapproved origin"

Add a temporary @font-face rule pointing at a third-party host, reload, and read the console:

Refused to load the font 'https://fonts.gstatic.com/s/inter/v13/example.woff2'
because it violates the following Content Security Policy directive: "font-src 'self'".

If that message does not appear, your font-src is missing or is being inherited from a permissive default-src. Directive layering and report collection are covered in Configuring Content Security Policy with SRI.

4. Confirm the runtime verifier fails closed

Permalink to "4. Confirm the runtime verifier fails closed"

Copy the font in a staging build, flip one byte, and point installVerifiedFont at the modified copy. The expected console output is the rejection path, not a rendered font:

font rejected, falling back to system stack TypeError: Failed to fetch

Text on the page must remain readable in the fallback stack. If the page renders blank glyphs instead, your font-family list has no system fallback and the failure mode is worse than the attack.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Can I put an integrity descriptor inside an @font-face rule?

No, and nothing will tell you it failed. CSS discards descriptors it does not recognise, so the rule still parses, the font still loads, and no console warning appears. There is no standardised integrity descriptor for @font-face in any CSS specification, so an integrity line inside the block is inert decoration rather than a control.

Does link rel=preload as=font with integrity protect the font?

It protects that one preload fetch. The HTML Standard allows integrity on a link whose rel contains preload, so the preloaded bytes are checked. The later @font-face fetch is a separate request that carries no integrity metadata of its own, and if it misses the preload entry it goes to the network unchecked. Treat preload integrity as a useful extra, never as enforcement.

Why did the specification authors leave fonts out?

Subresource Integrity attaches integrity metadata to a fetch that an element declares. Fonts are not declared by an element — they are requested by the CSS font loading machinery when a url() in a matched @font-face rule is needed for rendering. Covering fonts would require a new CSS descriptor and a way to thread it into the font fetch, and no such descriptor has been standardised.

What is the strongest protection available for a cross-origin font today?

Stop making it cross-origin. Copy the file into your own build output, give it a content-hashed filename, serve it immutably from your origin, and pin font-src to 'self'. If you must load third-party font bytes, fetch them yourself with the Fetch API integrity option and install the verified buffer through the FontFace constructor rather than a url() descriptor.

Does a malicious font file actually pose a risk?

Fonts do not execute script, so the risk profile is narrower than for a bundle. It is not zero: font parsing is native binary parsing and has produced memory-safety CVEs, a substituted font can alter rendered text in ways users cannot detect, and a swapped font URL is a reliable exfiltration beacon. The control set is proportionate rather than absent.

Permalink to "Related"

Related Articles

Adding Integrity to Google Fonts and CSS
SRI for Self-Hosted Web Fonts
Applying SRI to Stylesheets & Web Fonts Core SRI Fundamentals & Browse…