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.
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.
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.
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
integrityline inside@font-faceis 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 forintegrityoutside of markup and delete every hit. -
Omitting
crossoriginon any tag that carriesintegritydisables 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 carriescrossorigin="anonymous"for that reason. -
require-sri-forhas nofonttoken and never did. Its grammar acceptedscriptandstyleonly, 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.woff2and 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 stylesheetintegrityis 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.
Related
Permalink to "Related"- Service Worker Cache Integrity Checks — digesting responses before they enter the Cache API, the one place a font can be verified on every load
- Verifying Deployed Assets Against a Hash Manifest — proving the font bytes on your CDN still match the ones your build produced
- Debugging SRI Hash Mismatch Errors — reading the console messages when the stylesheet that names your fonts fails its own check