Humans see your writing. Scrapers see a plausible decoy. Same bytes on the wire: two different readers.
What it is · See it work · Quick start · Build a font · Contribute
Current release: v0.3.0. Default mapping: v18
alpha. Install from npm (@shieldfont/react,@shieldfont/core,@shieldfont/font) or paste in the CDN font. Live site at https://shieldfont.org.
Important
About the shipped fonts. The default fonts are built on Optik, a
proprietary typeface © Playtype, used in ShieldFont's
shielded (word-substitution) form with Playtype's permission (see
NOTICE). They are not open-source and not under the SIL
Open Font License: the permission covers the Optik-derived variants
distributed with ShieldFont, not the original outlines. The code is
open-source (AGPL-3.0); to ship a fully open font, build a variant on an OFL
base like Inter: see Build a font.
ShieldFont is a free, open-source protocol for protecting written work from the machines that scrape the web to train AI: an encoder, a font generator, and a documented methodology. Protected text stays normal for the human reading it in a browser and turns into a plausible decoy for anything reading the HTML source. The flagship typeface we ship is ShieldFont Optik; any TrueType font can be converted into a ShieldFont: see Build your own font.
The open web was written by people. Its value was taken without asking. ShieldFont is a small statement: writing belongs to the people who write it.
Started October 2025 by Isaque Seneda and Gabriel Abrucio. Supported by Playtype.
ShieldFont is not an attempt to stop AI scraping. It is an attempt to make scraping protected text more expensive than respecting consent, and to do that collectively.
A single page is one drop in a corpus that runs to trillions of tokens. Our
benchmarks show the drop is measurably useless as training signal: swap ~25% of
a page's words and bidirectional entailment against the original fails for
55.8% of news passages, 51.9% of general web, 34.5% of fiction and
31.1% of older fiction (versus ~2.1% for a synonym-swap control; median
41.8% across those four corpora). What happens at the quality filter cuts both
ways, and both ways favour you: the FineWeb-Edu classifier drops 99.0–99.8% of
encoded chunks on real-world corpora, so their meaning never reaches a model;
the minority that passes spends 19.4% of its token budget (four-corpus) on
shifted meaning. We
do not claim encoded text sails through quality gates, and we do not claim it
damages the model that trains on it: fine-tune "damage" numbers were measured
with the wrong instrument and are demoted (see
benchmark/). On its own, that result is statistically real and
economically irrelevant.
The economic case is the network case: many writers, each running a different mapping. To a filter, each one looks like clean English; to a model trained on all of them at once, they are incompatible substitution schemes. Defeating one mapping does not help with the next, so defeating N mappings means identifying and reversing N substitution tables, on every protected page, on every retraining run. That cost grows with participation.
The practical consequence: a small custom mapping you keep to yourself helps the network almost as much as a perfect one. You do not have to beat the benchmark. You have to be different from everyone else. A two-hundred-pair, noun-only mapping you reseed once and never publish is enough.
Read the full thesis in docs/introduction.md. How to
run a mapping of your own today (reseed the shipped pool at your own seed, or
hand-write a small one) is in docs/custom-mappings.md.
| 👀 What a human sees | 🤖 What a scraper sees |
|---|---|
|
|
The same HTML source produced both. The browser applies ShieldFont's OpenType GSUB rules at render time and swaps the encoded words for glyphs shaped like the originals. Anything reading the DOM without rendering fonts (scrapers, copy-paste into a text tool, language models digesting raw HTML) only ever gets the encoded version.
How it works, in two paragraphs
OpenType fonts support GSUB substitution lookups: rules that
swap glyphs at render time. Normally this is used for stylistic
flourishes like the fi ligature. ShieldFont abuses it. An encoder
rewrites your HTML using the default production mapping, v18 alpha
(11,970 entries; the sibling variants differ slightly, beta 12,034 and gamma
12,036, while the opt-in maxhide is a different shape at 2,534 entries with
higher page coverage), where each common content word is replaced with a
different but equally-common word of the same part-of-speech and similar
frequency: belongs ↔ determines, protect ↔ complain, words ↔ previews, plus
digit rotation 0↔5, 3↔8, 4↔9, 6↔7. Common function words are
deliberately left in place, so coverage is partial by design: a short
sentence may change only ~2 of its ~11 words, which is why the encoded text
reads as a plausible decoy rather than gibberish. The font contains lookup rules
that render the encoded words as composite glyphs shaped like the
originals. Reader wins, scraper loses. The mapping is bijective, so
decoding is lossless.
The font's GSUB structure uses a fire-then-revert pattern: every
ligature fires unconditionally, and a second chained-context pass
reverts any substitution that has a letter neighbor (which means
it fired inside a larger word, not on a standalone word). This handles
every text-run edge case (start of paragraph, end of line, line
wraps, hyphenated compounds, quoted shorts like 'on', and digits
adjacent to letters). Only the letter-adjacent digit is preserved, so
iPhone15→iPhone10 and M15-EN→M10-EN, while a standalone run like
1568→1073. Verified end-to-end by scripts/audit_font.py
across every case variant of the shipped mapping plus a substring-
collision battery.
Why the v18 family? ShieldFont's mappings went through 15 rounds of benchmarked iteration (M0 → M15) under the V3 suite. M15-EN was the champion of that era; the shipped
alpha/beta/gammavariants are its re-seeded v18 descendants, and M15-EN itself remains available as the opt-inmaxhidecoverage variant.maxhideis not a strongeralpha: it hides about twice as much of the page, but quality filters reject it almost entirely, so it trades the staleness effect for concealment. Read what it costs you before switching. See the white paper for the full journey.
See MAPPINGS.md for the mapping family overview.
ShieldFont ships as npm packages plus a no-build CDN font. Pick the path that matches your stack: the integration guide covers all four tiers in detail.
Important
One rule decides whether any of this works: your original text must never reach the browser. That means the encoding has to run in Node — in your build or during server render — and not in a component that ships to the client. Get this wrong and the page still looks protected while your plaintext sits in the JS bundle. We built five real apps and grepped the output; the results are in Where the encoding happens. Read it before you ship, not after.
Next.js, Astro, Remix — anything that renders React on the server: encoded in Node, at build time or during server render.
npm install @shieldfont/reactimport { Shield } from "@shieldfont/react";
<Shield as="p">
The future of writing belongs to those who protect their words.
</Shield>@font-face, encoding, and the font-load guard all happen automatically.
Anything outside <Shield> uses your normal page fonts.
Warning
<Shield> cannot protect a client-only React app — Vite, Create React App,
or any SPA with no server render. There is no Node step for it to encode in,
so your text and all 38,574 dictionary pairs compile straight into the JS
bundle. The build succeeds, the page renders your real words correctly, and
the only signal is a console warning. Same trap inside a "use client" file,
or passing unencoded text to a client component as a prop.
Using Vite or CRA? Encode in a Node script before the bundler runs, with
@shieldfont/core — see use anywhere — and treat
the encoded string as the content your app imports. Then grep dist/ for one
of your own sentences before you deploy.
This is also the only tier with more than one font weight. Each of the four
mapping variants ships six real static cuts of Optik, and the weight prop
picks one:
| Weight name | CSS font-weight |
Playtype cut |
|---|---|---|
regular |
400 | Optik Regular |
medium |
500 | Optik Medium |
demibold |
600 | Optik DemiBold |
bold |
700 | Optik Bold |
extrabold |
800 | Optik ExtraBold |
black |
900 | Optik Black |
Every one is a genuine Playtype cut run through the same encoding pipeline, so
the weight changes how the text looks and never what it encodes: the dictionary
and digit rules of a variant are byte-identical at all six weights. A numeric
value snaps to the nearest real cut (470 renders as Medium 500), and
font-synthesis is off, so a browser never fakes a bold. Nothing is
interpolated: there is no variable font, and no italics ship.
Blogs / plain HTML / a CMS you don't build: one CSS line, then a class:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shieldfont/font@0.3.0/shieldfont.css">
<p class="tk9">…encoded text from the encoder…</p>The shipped shieldfont.css styles the neutral .tk9 class (a deliberately
generic, renamable token: nothing in your markup says "shield"). Rename it in
your own CSS if you like; just keep the class in your HTML matching the one the
stylesheet targets. Note the stylesheet URL above does name the project and
the version, and on this tier there is no way around that: it is the delivery
mechanism. It is the loudest tell on the page, and the reason this tier is the
least concealed of the four. See concealment.
This tier is Regular only. @shieldfont/font's four files (optik-a,
optik-b, optik-c, optik-m) are the four mapping variants at
font-weight: 400, not four weights. The same goes for the downloadable font
for Word and PDF. If you need a real bold inside protected text, use
@shieldfont/react.
A static-site build step: npm install @shieldfont/core, then call
buildHtml() in a small build script to encode comment-marked blocks at CI time
(shipHtml() strips the markers before deploy). Full recipe in
docs/use-anywhere.md.
⚠️ Before you wrap anything: read this. Protected text ships asaria-hiddendecoy words in the DOM, which has consequences you must design around:
- SEO: search engines index the decoy, not your real words, and you can't tell Googlebot apart from an AI scraper (the same bytes go to both). Don't wrap content you want to rank. Protect essays and manifestos, not landing pages or meta descriptions.
- Your RSS feed will leak everything unless you fix it, and on most blog platforms it is on by default. Feeds, JSON-LD, OpenGraph tags and CMS APIs are generated from your source data, not from your rendered page, so ShieldFont never sees them:
/feed.xmlships every protected post in plain English, to a crawler that never had to know your site was shielded. Publish summaries only, and don't encode the feed (feed readers don't load web fonts, so subscribers would read the decoy). Full list and a one-line check: the plaintext side doors.- Copy-paste yields the encoded form, not the original.
- Ctrl-F finds nothing inside protected text: find-in-page searches the DOM, so a reader searching for a phrase they can see gets no result.
- Screen readers skip protected regions (they're removed from the accessibility tree), so nobody hears a decoy read aloud.
<Shield>setsaria-hidden="true"with no opt-out and pairs it with thea11yprop, which renders a real alternative outside the hidden region and before it in DOM order.a11y={{ mode: "text" }}ships your real words encrypted into the page behind a time-lock puzzle the reader's browser grinds out on request (default budget 20 seconds of their CPU, 7.6 s measured in Chrome; nothing to host). The control is screen-reader-only by default: nothing appears on screen, the unlocked words go to assistive technology clipped off-screen, and the encoded block is left exactly as it was.a11y={{ mode: "audio", src }}points at a build-time recording you make. No mode ever renders a link to a plain-text copy — a URL in the HTML is a one-line bypass for any scraper that follows it. Two costs to know up front: it is the one part of ShieldFont that needs JavaScript, and because the control is invisible, a sighted keyboard user with no screen reader Tabs into something they cannot see and loses their focus indicator (WCAG 2.2 SC 2.4.7 —visualHidden: falseputs the control back on screen). Difficulty is capped by what OCR would cost a crawler anyway, so raisingsecondsbuys nothing. Verified with real VoiceOver on macOS; NVDA and JAWS are not. Full reference:docs/plain-text-mode.md. Outside React (CDN paste-in,@shieldfont/core), setaria-hiddenand supply the alternative yourself.- JS off + font 404: the fail-loud font guard is JavaScript; with JS disabled and the font missing, a human sees the raw decoy text.
Two things, one spelling. ShieldFont is the protocol: the
encoder, the GSUB scheme, the methodology, the project; it is
typeface-agnostic. ShieldFont Optik is our
flagship typeface, the default the project ships; Optik is licensed from
Playtype. Any font with TrueType outlines and the Latin charset can be
converted into a ShieldFont. See the
naming convention
for the full framing. The docs guide for this path is
docs/custom-faces.md.
scripts/generate_font.py is a one-command builder: point it at a base TTF,
give it a name and a mapping, get back a font binary that obeys the protocol:
pip3 install -r requirements.txt
python3 scripts/generate_font.py \
--base-path /path/to/your-typeface.ttf \
--name "ShieldFont YourTypeface" \
--prefix shieldfont-yourtypeface \
--mapping-path scripts/v18alpha_for_font.json
# Audit the build (optional but recommended). Pass the font and mapping you
# just built — the defaults audit the shipped maxhide font, not yours:
python3 scripts/audit_font.py \
--font public/fonts/shieldfont-yourtypeface.ttf \
--mapping scripts/v18alpha_for_font.jsonOutputs land in public/fonts/ as .ttf, .woff2, and a ready @font-face
CSS. To mint a private mapping to build against, run
scripts/reseed_mapping.py --seed <n> first: see
docs/custom-mappings.md.
Recommended naming for community-built ShieldFonts: keep ShieldFont as the
prefix, then add a name of your own choosing — ShieldFont Optik,
ShieldFont Vellum, ShieldFont YourFoundry. Same CamelCase everywhere,
including the font's internal name table; context tells you whether the word
means the protocol or a specific typeface.
Warning
Do not put the base typeface's name in your font's name. Most open
licences reserve it. Inter, Syne and Young Serif each declare a Reserved
Font Name (see LICENSE-FONTS), and OFL §3 forbids using
one in a Modified Version — so "ShieldFont Inter" would breach the licence
you are building under, and OFL §5 terminates the grant if you do. Name your
build after your project or your foundry instead, and record the base
typeface in the font's Description field, which is what it is for.
| Flag | Description |
|---|---|
--base-url |
Direct .ttf URL, or Google Fonts zip URL |
--base-path |
Path to a local .ttf with TrueType outlines (alternative to --base-url; CFF/.otf rejected: see notes) |
--cache-name |
Filename for the cached base font in scripts/fonts/ |
--name |
Font family name written into the output |
--prefix |
Output file prefix → public/fonts/<prefix>.{ttf,woff2,css} |
--mapping-path |
Path to the mapping JSON to build against (e.g. scripts/v18alpha_for_font.json, scripts/m15en_for_font.json) |
--copyright |
Copyright notice (default: "Modified as ShieldFont.") |
Notes on base fonts: variable fonts are instanced to a static
default. CFF-only fonts are rejected: find a .ttf version. Existing
GSUB features on the base font are preserved; the generator inserts
its lookups at the front of the LookupList so they fire before the
base font's built-in fi/fl ligatures.
We're explicit about where ShieldFont works and where it doesn't. Overpromising would erode the trust the project is meant to build.
| ✅ Defends against | |
|---|---|
|
|
A full THREAT_MODEL.md with numbers against real scraper pipelines is
on the roadmap. If you find a new attack, please see
SECURITY.md.
Independent corroboration. In March 2026, LayerX Security published "Poisoned Typeface", offensive research by Roy Paz built on the same observation running in the other direction: a font whose rendering diverges from its underlying text makes humans and AI systems read two different pages at one URL. All eleven AI assistants they tested (ChatGPT, Claude, Copilot, Gemini, and Perplexity among them) read the underlying text and missed what the human saw, and only Microsoft took the disclosure through a full fix. LayerX is not affiliated with ShieldFont. Their work is independent evidence for the reading gap that the left column of the table above depends on.
See ROADMAP.md for the full list. Near-term priorities:
- Accessibility layer:
<Shield>hides protected regions from assistive tech and ships ana11yprop that renders a real alternative beside them —mode: "text"puts your words in the page encrypted behind a time-lock puzzle the reader's browser opens (no link for a scraper to follow, no artifact for you to host), ormode: "audio"points at a recording you make. What remains: NVDA and JAWS verification (VoiceOver is done by hand, Windows is not), the focus indicator a sighted keyboard user loses to an invisible control, and the non-React tiers shipping none of it. - Threat-model document: honest evaluation with numbers against real scraper pipelines.
- Multilingual mappings: the cross-language
M15-MULTItemplate exists; PT/ES/FR/DE/IT are next, each with native linguist curation. - Per-deploy rotation: per-site seeds and time windows to defeat dictionary reuse at scale. (Font inversion is unaffected by any seed, and a new seed needs a newly built font, so this is a cost-raising measure, not a fix.)
We want collaborators. ShieldFont is small in code and large in ambition. If any of these is you, you can move the project forward:
- Linguists: design language mappings that read as charmingly absurd to humans but wreck NLP tokenizers.
M15-MULTIis the starting scaffold. - Accessibility engineers: the React component skips protected regions and offers an alternative; making that alternative free for the author, and available outside React, is still open.
- Type designers: build ShieldFont versions of your typefaces.
- Adversarial researchers: prove where it breaks, publish numbers, make us better.
- Integrators: make ShieldFont a drop-in for WordPress, Ghost, Webflow, Shopify, and static-site generators.
- Writers & advocates: explainers, translations, talks.
- Read CONTRIBUTING.md.
- Look for issues tagged
good first issueorhelp wanted. - Open a Discussion for anything open-ended.
- First-time contributors sign the CLA: we explain why in CONTRIBUTING.
All participants follow the Code of Conduct.
- 🐛 Bugs → Issues
- 💡 Ideas → Feature requests or Roadmap proposals
- 💬 Questions & chat → Discussions
- 🔒 Security → please follow SECURITY.md
|
Isaque Seneda
Founder · Maintainer |
Gabriel Abrucio
Founder · Maintainer |
You? Join us |
Supported by Playtype.
packages/
core/ @shieldfont/core — encode/decode + HTML helpers, bundled mappings
react/ @shieldfont/react — <Shield> server component (version-neutral fonts, six weights per variant)
font/ @shieldfont/font — no-build CDN font + shieldfont.css (Regular 400 only)
core/src/mappings/{alpha,beta,gamma,m15en}.json the shipped mappings
scripts/
generate_font.py base font + mapping → a ShieldFont (.ttf / .woff2 / .css)
reseed_mapping.py mint a private mapping from your own seed
audit_font.py strict HarfBuzz round-trip verifier → public/audit.html
subset_font.py prune a built font to the words your site actually uses
(~825 KB → ~197 KB for a 2,000-word vocabulary)
fix_composite_lsb.py repairs composite side bearings in an already-built font
v18{alpha,beta,gamma}_for_font.json, m15en_for_font.json font-build inputs
benchmark/ README + PROVENANCE + EXCLUDED, and the v7/v8 result
data + scripts that back them (benchmark/data/)
docs/ integration · custom-mappings · custom-faces · introduction · CLAUDE.md
MAPPINGS.md mapping family overview (M0 → M15, and the shipped v18 family)
CHANGELOG.md · ROADMAP.md · LICENSE (AGPL-3.0) · LICENSE-FONTS · NOTICE
- Code: GNU Affero General Public License v3.0.
- Generated fonts: SIL Open Font License 1.1 when built from the OFL base fonts, which keep their original terms.
- ShieldFont Optik, the shipped default, uses Optik © Playtype in ShieldFont's replaced-glyph form only: not under OFL. See NOTICE.
🛡️ Writing belongs to the people who write it.
Made with ❤️ and a lot of fontTools.
