How to generate font fingerprints in your build pipeline
- Step 1Read each font's raw bytes — Walk `src/fonts/` and for each `.ttf/.otf/.woff/.woff2` read the file with `fs.readFileSync`. Hash the bytes verbatim — do not decode or transcode first, exactly like the tool, so your hash matches the Fingerprinter and `shasum -a 256`.
- Step 2Compute the hash and short id — `const hex = crypto.createHash('sha256').update(buf).digest('hex')`. The `short_hash` is `hex.slice(0, 8)`. These are the same two values the tool's metric chips and JSON show.
- Step 3Build the cache-busted filename — Mirror the tool: `${stem}.${short}.${ext}`, where `stem` is the basename without extension and `ext` is the original extension (default `woff2` if somehow absent). This keeps your script's names identical to a manual run.
- Step 4Build the SRI attribute — `const b64 = crypto.createHash('sha256').update(buf).digest('base64')`; the attribute is `integrity="sha256-${b64}"`. It is the same digest as the hex, just base64-encoded — never a second, separate hash.
- Step 5Emit a manifest and (optionally) rename into dist/ — Write `fonts.manifest.json` keyed by source filename: `{ hash, shortHash, hashedFilename, sriAttribute }`. Copy each font to `dist/fonts/<hashedFilename>` so `src/` stays clean and `dist/` holds the deployable, cache-busted assets.
- Step 6Consume the manifest in CSS / HTML generation — Your `@font-face` emitter reads `hashedFilename` for `src: url(...)`; your `<head>` template reads `sriAttribute` for preload integrity. Run the whole thing as an npm `prebuild` so every production build is fingerprinted before bundling.
Tool output ↔ Node equivalent
Each Fingerprinter field reproduced with stock Node crypto. buf is fs.readFileSync(path); stem/ext are derived from the filename. Outputs are byte-identical to the browser tool because both hash the same raw bytes.
| Fingerprinter field | Node expression | Notes |
|---|---|---|
sha256 | crypto.createHash('sha256').update(buf).digest('hex') | 64-char lowercase hex |
short_hash | hex.slice(0, 8) | First 8 hex chars of sha256 |
cache_busted_filename | ${stem}.${short}.${ext} | ext = original extension or woff2 fallback |
sri_attribute | integrity="sha256-${crypto.createHash('sha256').update(buf).digest('base64')}" | Same digest as hex, base64-encoded |
file_size | buf.length | Bytes, echoed for the manifest record |
Where to run it
Three places to fingerprint in a pipeline, from fully hand-rolled to fully through JAD.
| Approach | How | When to choose it |
|---|---|---|
| Hand-rolled Node script | crypto.createHash('sha256') over each file in a prebuild | Default — zero deps, fastest, fully in your control |
| @jadapps/runner (local HTTP) | POST 127.0.0.1:9789/v1/tools/font-fingerprinter/run per file | You already use the JAD runner for other font steps and want one pipeline |
Bundler [contenthash] | Vite/Webpack emit hashed names; fingerprint only to verify | The bundler owns the assets; you just want a sanity check |
Cookbook
A complete dependency-free script plus the manifest it produces. Drop it in scripts/fingerprint-fonts.mjs and wire it as an npm prebuild.
The whole script (≈30 lines, zero deps)
ExampleWalks src/fonts, reproduces all four Fingerprinter fields, copies hashed files into dist/fonts, and writes the manifest. Output is byte-identical to the browser tool.
import { readFileSync, writeFileSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, basename, extname } from 'node:path';
const SRC = 'src/fonts', OUT = 'dist/fonts';
mkdirSync(OUT, { recursive: true });
const manifest = {};
for (const name of readdirSync(SRC)) {
if (!/\.(ttf|otf|woff2?|)$/i.test(name)) continue;
const buf = readFileSync(join(SRC, name));
const hex = createHash('sha256').update(buf).digest('hex');
const short = hex.slice(0, 8);
const ext = extname(name).slice(1) || 'woff2';
const stem = basename(name, extname(name));
const hashedFilename = `${stem}.${short}.${ext}`;
const b64 = createHash('sha256').update(buf).digest('base64');
copyFileSync(join(SRC, name), join(OUT, hashedFilename));
manifest[name] = {
hash: hex, shortHash: short, hashedFilename,
sriAttribute: `integrity="sha256-${b64}"`, fileSize: buf.length,
};
}
writeFileSync('fonts.manifest.json', JSON.stringify(manifest, null, 2));The manifest it writes
ExampleOne entry per source font. Templates read hashedFilename for src URLs and sriAttribute for preload integrity.
{
"Inter-Regular.woff2": {
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"shortHash": "e3b0c442",
"hashedFilename": "Inter-Regular.e3b0c442.woff2",
"sriAttribute": "integrity=\"sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\"",
"fileSize": 30696
}
}Wire it as an npm prebuild
ExampleRun the fingerprinter before the bundle so hashed assets and the manifest exist when the build references them.
{
"scripts": {
"prebuild": "node scripts/fingerprint-fonts.mjs",
"build": "next build"
}
}Generate @font-face from the manifest
ExampleA tiny emitter that turns the manifest into the deployable CSS, so the src URLs always match the hashed files in dist/.
import { readFileSync, writeFileSync } from 'node:fs';
const m = JSON.parse(readFileSync('fonts.manifest.json', 'utf8'));
const css = Object.entries(m).map(([src, e]) =>
`@font-face {\n font-family: "${src.split('-')[0]}";\n font-display: swap;\n src: url("/fonts/${e.hashedFilename}") format("woff2");\n}`
).join('\n\n');
writeFileSync('dist/fonts.css', css);Or call the JAD runner instead of hand-rolling
ExampleIf you already run the @jadapps/runner for other font steps, POST the font to the local tool endpoint and read back the same JSON the browser tool produces.
# pair once, then per file:
curl -s -X POST \
http://127.0.0.1:9789/v1/tools/font-fingerprinter/run \
-F 'file=@src/fonts/Inter-Regular.woff2' \
| jq '{sha256, short_hash, cache_busted_filename, sri_attribute}'
# the option schema (empty for this tool):
# GET /api/v1/tools/font-fingerprinterEdge 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.
Decoding before hashing breaks the match
Hash mismatch / fails verifyIf your script parses the font (opentype.js, fonttools) and re-serialises before hashing, the bytes differ from the file on disk and your hash will not match the JAD Fingerprinter, shasum, or your CDN's ETag. Hash the raw fs.readFileSync buffer exactly as the tool does. Parse only for analysis steps, never for fingerprinting.
Non-deterministic font generation upstream
Churny diffsIf a previous build step regenerates the WOFF2 (e.g. a subsetter that embeds a timestamp or uses a different encoder version), the font's bytes change every build and so does the hash — fonts.manifest.json churns on every commit. Pin the encoder/subsetter version and disable timestamp embedding so unchanged inputs produce unchanged outputs. Then the manifest is stable and re-runs are true no-ops.
Line-ending or BOM normalisation by git
Corruption — font invalidFonts are binary; a .gitattributes rule that applies text=auto / EOL normalisation to font files will silently corrupt them and change every hash. Mark fonts as binary: *.woff2 binary (and the same for ttf/otf/woff). This is a frequent cause of 'the hash changed but I didn't touch the font'.
Globbing picks up a non-font file
Garbage in manifestA loose extension regex can sweep in .DS_Store, .map, or a stray .txt, hashing non-fonts into the manifest. The script hashes whatever it reads — it does not validate the format. Tighten the filter to \.(ttf|otf|woff|woff2)$ and, if you need certainty, gate on font-format-identifier before fingerprinting.
Two source fonts produce the same hash
Expected dedupe signalIf Inter-Regular.woff2 and a duplicated body.woff2 share a sha256, they are byte-identical — you're shipping the same file twice under two names. Use the hash as a dedupe key in the manifest to ship one physical asset and alias the rest. SHA-256 collisions are not a concern in practice.
Runner not paired but pipeline expects it
Connection errorThe @jadapps/runner binds to loopback 127.0.0.1:9789. If CI tries the runner endpoint without the runner running (or paired), the POST fails with connection refused. For headless CI, prefer the hand-rolled crypto.createHash script — it has no runtime dependency and produces identical output.
Source extension lost during copy
Falls back to woff2Like the tool, the script defaults ext to woff2 when a filename has no extension. If an upstream step strips extensions, your hashed filename ends .<short8>.woff2 regardless of the real format. Preserve extensions through the pipeline, or set ext explicitly from the detected format.
Manifest committed vs generated
Process choiceDecide once: either commit fonts.manifest.json (so a review sees every hash change explicitly — good for supply-chain auditing, see the supply-chain guide) or .gitignore it as a build artifact. Mixing the two causes spurious diffs when CI regenerates a committed file. Pick the auditable path if integrity matters.
Frequently asked questions
Will my Node script's hash match the JAD Fingerprinter exactly?
Yes, byte-for-byte, as long as you hash the raw file buffer (fs.readFileSync) without decoding it first. The tool uses crypto.subtle.digest('SHA-256', bytes); Node's crypto.createHash('sha256').update(buf) is the same algorithm over the same input. Both also match shasum -a 256 and sha256sum on the file. The only way to diverge is to transform the bytes before hashing.
Should I use the full hash or the short prefix?
Short (8 hex chars) for filenames — it keeps URLs readable and collisions across a normal font set are negligible. Full (64 chars / the base64 SRI) for integrity attributes and design-system pins, where you want cryptographic strength. The Fingerprinter emits both; reproduce both in your script so each consumer uses the right one.
How do I make re-runs idempotent?
Ensure the inputs are deterministic. If the font bytes don't change, the SHA-256 doesn't change, so the manifest and hashed filenames don't change and the diff is empty. Churn comes from upstream non-determinism — a subsetter that embeds timestamps, an encoder that varies output, or git EOL-normalising a binary. Pin tool versions and mark fonts as binary in .gitattributes.
Where do I keep the CDN URL prefix?
Out of the manifest. Store only relative hashedFilename values; let your HTML/CSS templates prepend the CDN origin at emit time. Decoupling means you can swap CDNs without re-fingerprinting — the hashes describe content, not location.
How do I integrate this with Next.js?
Run the script as a prebuild. Place hashed fonts in public/fonts/ (or dist/fonts/ if you serve statically) and reference /fonts/<hashedFilename> from CSS. Next.js serves the hashed file directly with the cache headers you configure. Generate the @font-face CSS from the manifest so the src URLs always match the deployed names.
Can I run the JAD tool in CI instead of writing a script?
Yes — pair the @jadapps/runner and POST each font to 127.0.0.1:9789/v1/tools/font-fingerprinter/run; the option schema (empty) is at GET /api/v1/tools/font-fingerprinter. The response is the same JSON the browser tool produces. For dependency-free CI most teams prefer the hand-rolled crypto.createHash script since it needs nothing installed and is identical in output.
Does the script upload my fonts anywhere?
No. A pure Node crypto.createHash script runs entirely on your CI machine — no network call at all. Even the JAD runner option is loopback-only (127.0.0.1). Your fonts, including licensed or pre-release faces, never leave the build environment.
How do I batch when the browser tool is single-file?
The browser tool processes one font at a time (multiFile: false), which is why automation matters for a design system. The script loops over readdirSync(SRC) and fingerprints each file, so a 40-file family is one prebuild invocation. There is no need to drag fonts in one by one.
What about variable fonts — does the axis affect the hash?
The hash is of the whole file's bytes, so a variable font fingerprints as a single value regardless of which named instances it carries. If you freeze an instance to a static file with variable-font-freezer, that's a different file with different bytes and therefore a different hash — fingerprint the frozen output separately.
Can I also gate the build on a known-good hash?
Yes — that's the supply-chain pattern. Store expected hashes, recompute on every build, and fail CI on any mismatch so a tampered or accidentally-updated font can't ship silently. The supply-chain security guide covers the pin-and-verify workflow in detail.
Why copy into dist/ instead of renaming in place?
Copying keeps src/fonts/ with stable human-readable names (good for source control and editing) while dist/fonts/ holds the cache-busted deployables. Renaming in place would churn your source tree on every byte change and make diffs unreadable. The script writes hashed names only to the output directory.
Does the script need any npm packages?
No — node:fs, node:crypto, and node:path are all built in. That's deliberate: a zero-dependency fingerprint step has no supply-chain surface of its own, which matters when the whole point is integrity. The only thing you add is the ≈30 lines above.
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.