How to use font fingerprints for supply chain security
- Step 1Establish the baseline — On first audit, fingerprint every font you depend on — self-hosted and third-party alike. Drop each through the tool and record the full `sha256` in `fonts.expected.json` keyed by filename: `{ "Inter-Regular.woff2": "e3b0c442…b855" }`.
- Step 2Vendor third-party fonts you don't control — For fonts you currently hotlink (Google Fonts, Fontshare, Adobe CDN), pull them down and self-host so you can pin a fixed byte stream. Use [google-fonts-css-generator](/font-tools/google-fonts-css-generator) to produce the self-hosted `@font-face` during migration.
- Step 3Verify on every build — A CI step recomputes each font's SHA-256 (the same hash the tool emits — `crypto.createHash('sha256')` over the raw bytes) and diffs against `fonts.expected.json`. Any mismatch is a failed check.
- Step 4Block on mismatch — Treat a hash change like a failed test: stop the deploy and surface which font drifted. This catches both an accidental upstream update and a malicious swap — neither can ship without a human seeing it.
- Step 5Update pins deliberately — When you intend to bump a font, re-fingerprint and update `fonts.expected.json` in the same PR. Reviewers see the hash change explicitly and acknowledge it. There is no silent path to production for a changed font.
- Step 6Verify in the browser too — Add the tool's `sri_attribute` to `<link rel="preload" as="font" crossorigin>`. Even if a CDN or proxy tampers post-build, the browser hashes the delivered bytes and refuses a mismatch — closing the gap between your build artifact and what the user actually loads.
Two layers of verification
Build-time pinning and browser-side SRI catch different threats at different moments. Use both.
| Layer | What it catches | When it fires | Fingerprinter field |
|---|---|---|---|
| Build-time hash pin | Upstream font change, dependency swap, accidental re-save | In CI, before deploy | sha256 (stored in fonts.expected.json) |
| Subresource Integrity | Tampering in transit / at a CDN or proxy after build | In the user's browser, at fetch | sri_attribute |
| Filename pinning (defence) | Stale/ambiguous versions; pairs with caching | At fetch / cache lookup | cache_busted_filename |
Third-party font sources and their drift risk
Common font origins and why you'd want to pin each. Drop a downloaded copy through the Fingerprinter to capture the baseline sha256.
| Source | Why fonts can change under you | Mitigation |
|---|---|---|
| Google Fonts CDN (hotlinked) | Google updates font versions and subsets server-side; you get whatever is current | Self-host + pin; migrate with google-fonts-css-generator |
| Fontshare / foundry CDNs | Versioned silently; a refresh can change hinting, metrics, or glyph set | Vendor the file, pin its sha256, verify in CI |
| npm font packages | A new package version (even a patch) can rebuild the binary | Lockfile + pin the resolved font's hash, not just the version |
| Shared internal asset bucket | Another team can overwrite a font with the same name | Pin the hash; block deploy on mismatch; add SRI |
Cookbook
The pin-verify-block workflow as files and commands. The expected hash is the tool's full sha256; the verify step recomputes the identical value.
The pin file
Examplefonts.expected.json — the known-good full sha256 for each font, captured from the Fingerprinter on first audit.
{
"Inter-Regular.woff2": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"Inter-Bold.woff2": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}The CI verify step
ExampleRecompute each font's hash and fail the build on any mismatch. Same SHA-256 over the same raw bytes as the tool, so a clean repo passes silently.
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
const expected = JSON.parse(readFileSync('fonts.expected.json', 'utf8'));
let ok = true;
for (const [file, want] of Object.entries(expected)) {
const got = createHash('sha256')
.update(readFileSync(`public/fonts/${file}`)).digest('hex');
if (got !== want) {
console.error(`DRIFT: ${file}\n expected ${want}\n got ${got}`);
ok = false;
}
}
process.exit(ok ? 0 : 1);A blocked deploy looks like this
ExampleWhen an upstream font changes without an intentional pin bump, the check fails loudly and the deploy stops.
$ node scripts/verify-fonts.mjs DRIFT: Inter-Regular.woff2 expected e3b0c44298fc1c14...7852b855 got 4a8e9f01c2b3d4e5...0099aabb Error: font integrity check failed — investigate before deploy
Intentional bump in a reviewable PR
ExampleUpdating a font is a two-line, explicit diff that a reviewer must approve — no font changes silently.
"Inter-Regular.woff2": - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "4a8e9f01c2b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a0099aa", # PR title: chore(fonts): update Inter to 4.1 (re-fingerprinted)
Browser-side SRI as the second layer
ExampleEven after a clean build, a tampering proxy can swap bytes. SRI makes the browser reject anything that doesn't match the pinned hash.
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/fonts/Inter-Regular.woff2"
integrity="sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=">
<!-- browser hashes the delivered bytes; mismatch → font refused -->Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Upstream re-save flips the hash with no visible change
Drift — investigateA foundry re-exports a font (new head timestamp, re-ordered tables) and the rendering looks identical, but the sha256 changes. Your pin fails. This is correct: you can't tell a benign re-save from a malicious swap by looking — only by reviewing the source of the change. Treat every drift as needing acknowledgement, then bump the pin deliberately if the new bytes are trusted.
Hotlinked CDN font can't be pinned
UnpinnableIf you <link> straight to a third-party CDN, you receive whatever they serve at request time — you have no fixed bytes to pin and can't verify in CI. The only durable fix is to vendor (self-host) the font so you control the byte stream. Until then, browser-side SRI on the hotlink is your sole check, and it breaks every time the CDN updates.
SRI on a hotlinked font breaks on every upstream update
Integrity check failsAdding integrity to a third-party CDN font means the moment the CDN ships a new version, the hash mismatches and the font silently fails to the system fallback. That's SRI doing its job, but it also makes your site fragile to their release cadence. Self-host and pin so updates are a deliberate PR, not a surprise outage.
Version pinned but binary still changed
Drift behind the versionAn npm font package or a 'v4.1' download can be rebuilt without bumping the human-facing version, so pinning the version string alone isn't enough. Pin the hash of the resolved binary, not the version label. Lockfiles help for npm but don't guarantee the binary inside hasn't been republished — the SHA-256 is the ground truth.
Same hash after a 'security update' from upstream
Expected — no changeIf upstream claims an update but your fingerprint is unchanged, the bytes didn't actually change — the 'update' was a no-op for that file. Equal hashes mean equal bytes; no action needed. This is useful for cutting through vague advisories: re-fingerprint and see whether your actual artifact moved.
Build-time pass, runtime tamper
Caught only by SRIYour CI verifies the artifact, but a compromised proxy or CDN edge swaps the font on the way to the user. Build-time pinning can't see this — only browser-side SRI can, because it hashes what actually arrived. This is exactly why you run both layers; neither alone covers the full path from build to glyph.
Font is valid but parser-vulnerable
Out of scope for hashingA fingerprint proves a font's bytes are exactly what you expect — it does not prove those bytes are safe to parse. Pinning protects against substitution, not against a vulnerability in a font you intentionally trust. Keep the renderer/OS patched and treat fonts from untrusted sources with the same caution as any executable input; consider font-format-identifier to confirm the format before trusting it.
Pin file drifts from the deployed fonts
False sense of securityIf fonts.expected.json isn't regenerated when you legitimately update fonts, either the build fails on every run (annoying) or someone disables the check (dangerous). Make the pin bump part of the same PR that changes the font, and keep the verify step mandatory. A skipped check is worse than no check because it looks protected.
Frequently asked questions
Has there been a real font supply-chain attack?
Font-specific supply-chain attacks are less common than JavaScript ones, but the surface is real: font files are parsed by complex, historically CVE-prone code, and any third-party-served font can be swapped. Best practice is to treat a font like any other third-party resource — vendor it, pin its SHA-256, verify on every build, and add SRI. The cost is a few lines of CI; the downside of skipping it is a silent rendering or parser exploit.
Should I self-host to avoid supply-chain risk?
Yes for any security-sensitive audience. Self-hosting removes the third-party CDN from the trust path entirely — you control every byte and can pin it. Migrate with google-fonts-css-generator to produce the self-hosted @font-face, fingerprint each downloaded file to capture the baseline, and pin it. Hotlinked fonts are inherently unpinnable because you receive whatever the CDN serves at request time.
Is SRI sufficient on its own?
No — SRI only verifies in the user's browser, at fetch time. It can't stop a bad font from being committed and built into your artifact, and it breaks awkwardly on hotlinked CDN fonts (every upstream update fails the integrity check). Build-time hash pinning catches drift before deploy; SRI catches tampering after. Use both: build-time for early detection, SRI for the build-to-browser segment.
What exactly should I pin — version or hash?
The hash. Version strings and even npm lockfile entries can resolve to a rebuilt binary with the same label, so pinning a version is not the same as pinning the bytes. The full sha256 from the Fingerprinter is the ground truth — it changes if and only if the bytes change. Store it in fonts.expected.json and verify against it.
How do I tell a malicious swap from a benign re-save?
You can't, from the bytes alone — both produce a different hash. That's the point: a hash check forces a human to look at the source of any change. When a pin fails, investigate where the new bytes came from (a trusted foundry release vs an unexpected overwrite). If trusted, bump the pin in a reviewed PR; if not, you just caught an incident.
Where does the SHA-256 come from and can I reproduce it in CI?
The tool computes it with crypto.subtle.digest('SHA-256', rawBytes) in your browser. In CI, crypto.createHash('sha256').update(fs.readFileSync(path)) produces the identical value over the same file — both also match shasum -a 256. So your verify step and the baseline you captured in the tool are guaranteed to agree as long as the bytes are unchanged.
Does pinning protect against a vulnerability in a font I trust?
No — it protects against substitution, not against flaws in the font you chose. A fingerprint guarantees the bytes are what you expect; it says nothing about whether those bytes trip a renderer bug. Keep the OS/renderer patched, and apply the same scrutiny to fonts from untrusted sources as to any executable input. Pinning is one control in a layered defence, not the whole defence.
How do I roll out pinning to an existing project?
Start in report-only mode: add the verify script but only log mismatches, while you self-host any hotlinked fonts and capture baselines with the Fingerprinter. Once fonts.expected.json is complete and stable, flip the script to fail the build. Then every future font change is a deliberate, reviewed pin bump. The build-script guide has the manifest tooling to base this on.
Do I need to upload fonts to fingerprint them for this?
No — and that matters for security work. The tool hashes locally via crypto.subtle.digest; the panel shows 0 bytes uploaded. Pre-release, licensed, or sensitive brand fonts never leave your machine to get a baseline hash. On paid tiers the work can route to the local @jadapps/runner on 127.0.0.1, still entirely local.
What about variable fonts and subsets — pin which artifact?
Pin the exact file you deploy. A variable font is one file with one hash; a subset or a frozen instance is a different file with a different hash. If your pipeline subsets (font-subsetter) or freezes (variable-font-freezer), pin the output of those steps, and ensure those steps are deterministic so the pinned hash is stable across builds.
Should fonts.expected.json be committed?
Yes — committing it is what makes a font change an explicit, reviewable diff. A reviewer sees the old and new hashes side by side and must approve. If you generate it as a build artifact instead, you lose the audit trail and the protection becomes advisory. For supply-chain assurance, commit the pins and bump them in the same PR as the font.
Can I block on more than fonts with the same pattern?
Absolutely — the pin-verify-block pattern generalises to any binary asset (images, icons, wasm). But fonts are a particularly good candidate because they're served from third-party CDNs, parsed by privileged code, and change under you without notice. Start with fonts where the risk-to-effort ratio is best, then extend the same fonts.expected.json approach to other assets.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.