An Angular library · Angular 20–22 · SSR ready
Readable by people.
Gibberish to b0t$.
Add one directive to your Angular templates. Visitors read your text as usual, but anything that scrapes your HTML (AI crawlers, scripts, copy-paste) gets scrambled nonsense.
npm i @pacyfist/veneer<p veneer>…</p>A protected sample paragraph follows. It is stored scrambled and repaired on screen by a generated font.
Hover the paragraph for bot vision.Drag across the paragraph for bot vision.
Try it yourself
Don't take our word for it
1. Copy it
A protected sentence you are invited to copy follows.
Your clipboard
Waiting for you to copy…
That's what ends up in a chatbot when someone pastes your article into it.
2. Search for it
A protected sentence naming a word to search for follows.
Press Ctrl + F (or ⌘ + F) and type the word from the line above.
3. Type your own
You see
A protected copy of your text. It is stored scrambled and repaired on screen by a generated font.
A bot reads
RM BroWrv Bdsor WrozGr
4. Look at what a crawler downloads
These tabs download this very page again, the way a scraper would, and show what is in the response, before any JavaScript runs.
How it works
A secret code, and a font that cracks it
- 1
Swap every letter
Each letter is swapped for a different letter, each digit for a different digit. That scrambled version is what goes into your HTML.
H↓Ie↓rl↓wl↓wo↓K - 2
Build a matching font
A font is a lookup table from character to shape. veneer copies your font with the table shuffled to match, so the slot for one letter draws another. Kerning and ligatures stay.
- slotIdrawsH
- slotrdrawse
- slotwdrawsl
- slotKdrawso
- 3
Eyes decode, code doesn't
Your screen draws shapes, so you read the word. A scraper reads characters, so it gets the code.
You see
Hello
A bot reads
IrwwK
It starts from a normal .ttf font you already use. With server rendering the server builds the font and scrambles the HTML; otherwise the browser does it when the page loads. There is no third-party service.
Get started
Add it to your Angular app in three steps
- Step 1
Install it
Also put a .ttf, .otf or .woff copy of your body font in
public/fonts/. WOFF2 needs converting first.npm install @pacyfist/veneer - Step 2
Point it at your font
Use the same typeface your page already uses, so protected text blends in. The fallback shows while the font loads and covers characters your font lacks. Keep the path relative (no leading slash) so it resolves against
<base href>and still works when the app is deployed under a subpath.app.config.tsimport { provideVeneer } from '@pacyfist/veneer';export const appConfig: ApplicationConfig = {providers: [provideVeneer({font: 'fonts/Roboto-Regular.ttf',fallbackFontFamily: "'Roboto', sans-serif",}),],}; - Step 3
Mark your text
Add
veneerto any element. That's it. People read it, andinnerTextgets noise.article.component.tsimport { VeneerDirective } from '@pacyfist/veneer';@Component({imports: [VeneerDirective],template: `<p veneer>Scrapers get gibberish here.</p>`,})export class Article {} - Server-rendered apps
Add the server half
Without it the server renders protected text readable. With it, the server forges the font and serves it, and the page carries only the font's URL, never the key. Prerendered sites copy the forged fonts into the build output once, after the build.
// app.config.server.tsimport { provideVeneerServer } from '@pacyfist/veneer/server';import { join } from 'node:path';const serverConfig: ApplicationConfig = {providers: [provideServerRendering(withRoutes(serverRoutes)),provideVeneerServer({ fonts: join(import.meta.dirname, '../browser') }),],};// server.ts, before the Angular request handlerimport { veneerFontMiddleware } from '@pacyfist/veneer/server';app.use(veneerFontMiddleware());
Strict Content Security Policy
On server-rendered pages veneer adds one tiny inline <style> and <script> that hide protected text until the font is ready. With dynamic SSR, provide Angular's CSP_NONCE and veneer uses it. A prerendered page like this one has no nonce, so allow them by hash:
| VENEER_CSP_HASHES | exported value | computed from this page's HTML | |
|---|---|---|---|
| script | 'sha256-zScTzaenp7O94uAEY9jA4ApwcUzDKRER9f1cpL7uzbU=' | computing… | |
| style | 'sha256-XoOItZbUxNa/iUajNPIswWzXGI7sNIjM3+RkxLnH6JI=' | computing… |
import { VENEER_CSP_HASHES } from '@pacyfist/veneer';const csp = `script-src 'self' ${VENEER_CSP_HASHES.script}; ` + `style-src 'self' ${VENEER_CSP_HASHES.style}`;provideVeneer({ font: 'fonts/Roboto-Regular.ttf', hideWith: 'hidden',}); Or nothing inline at all: hideWith: 'hidden' uses the HTML hidden attribute. Readers without JavaScript then see nothing, and the layout shifts when the text appears. Either way, veneer downloads fonts with fetch(), so they fall under connect-src, not font-src.
More for server-rendered apps
These need a running server, so this static demo cannot show them live. examples/basic-ssr in the repository runs all of them.
Several servers, restarts and redeploys
With a secret, every process and build forges the same fonts at the same URLs, and veneerFontMiddleware serves fonts any instance wrote to the shared folder, so a page still decodes when another server answers its font request.
// app.config.server.tsprovideVeneerServer({ fonts: join(import.meta.dirname, '../browser'), secret: process.env['VENEER_SECRET'], // openssl rand -hex 32 emitDir: '/srv/veneer-fonts', // storage every instance shares});// server.tsapp.use(veneerFontMiddleware({ dir: '/srv/veneer-fonts' }));Readable text for verified search crawlers
shouldProtect decides per request; verifyCrawler checks the visitor's IP by reverse and forward DNS. Dynamic SSR only: prerendered pages are always protected.
// app.config.server.tsprovideVeneerServer({ fonts: join(import.meta.dirname, '../browser'), shouldProtect: (request, context) => !(context as { crawler?: unknown })?.crawler,});// server.ts: reverse and forward DNS, never the user agent aloneapp.use(async (req, res, next) => { const crawler = await verifyCrawler(req.ip ?? '', req.get('user-agent')); angularApp .handle(req, { crawler }) .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) .catch(next);});Which form do I use?
Rule of thumb: if you see {{ }}, use the pipe. Otherwise, use the attribute. Each tab below runs live, with what a bot would read underneath.
<p veneer>Protected by element content.</p>For words written straight into the template. It is scrambled during server rendering too, so crawlers that read the HTML never see the real text. Like all template text, it is also in your JavaScript bundle in plain form.
Markup inside is fine
VzNjB; tKwq dNq zvdwzo PKWqB jrrG PKWjzND zNBzqr d GWKvrovrq GdWdDWdGi; wzjr vizB KNr, ZNwM vir vrbv zB BoWdxtwrq,
The attribute rewrites each text node in place and leaves the elements alone. code elements and anything marked veneer-skip stay readable.
HTML from a CMS: [veneerHtml]
Bind the HTML and veneer sanitizes it first, then protects its text.
The CMS sends
You see
pKBvrq tM Ada Lovelace, asN npm test; virN UKwwKP vir BrvsG Dszqr, gir UKNv qKrB vir qroKqzND,
A bot reads (the element's innerHTML)
A string is sanitized like [innerHTML]: the onclick and the <script> are gone, links, <strong> and <code> stay. The sanitizer drops attributes it does not know but keeps class, so the name marked class="veneer-skip" stays readable.
When it fails
Usually: your text is just text
Working normally
The decoder font loads and the text is protected.
The baseline, for comparison.
What monitoring receives (withStatusListener)
- Waiting for the sample…
VNR016 only happens while a server-rendered page hydrates and its cipher cannot be read, so these browser-drawn samples cannot produce it. This page's own status, from withStatusAttribute(): <html data-veneer-status="…">
The honest part
A speed bump, not a vault
Stops
- ✓Crawlers that read your HTML without running JavaScript
- ✓Scripts that read innerText or textContent
- ✓Copy-pasting your page into a chatbot
- ✓Bulk scrapers that have never heard of veneer
Doesn't stop
- ✕Crawlers that also download your JS bundles: text written in your templates is in there
- ✕Scrapers that know veneer and download the fonts: about 15 lines of code
- ✕Headless browsers that read the font from memory
- ✕Screenshots run through OCR or a vision model
- ✕A determined person targeting your site in particular
The attack matrix
Measured by the adversary suite, which runs these attacks against this demo on every CI run. You can run (b) and (d) yourself in "Look at what a crawler downloads".
| Attacker | Cost | Result |
|---|---|---|
| (a) Crawler: one GET and an HTML parser | 5 lines, ~1 s | BlockedGets only what the page prints in the clear outside protected elements. |
| (a') The same, also downloading the page's JavaScript | A few more lines | SucceedsFor text written in the source, which ships in the bundle. |
| (b) Veneer-aware crawler: regex a seed, rebuild the cipher | 15 lines, <1 s | BlockedThere is no seed in the page. |
| (c) Headless browser hooking FontFace | 56 lines, 1-2 s | SucceedsOnce JavaScript runs, the forged font is in memory. |
| (d) Font-aware crawler: download both fonts, no JavaScript | 15 lines, 1-2 s | SucceedsThe bar veneer sets: a font parser and two downloads. |
| Brute-forcing a seed | ~225 bits | BlockedInfeasible by default; trivial if the app fixes a seed. |
| Substitution solver on the ciphertext alone | Off the shelf | SucceedsOn long passages: spaces and word lengths are kept. |
| Screenshot plus OCR or a vision model | Rendering + inference | SucceedsThe pixels are correct by design. |
The details, and who veneer does not try to stop: THREAT_MODEL.md.
Check this page for leaks
Runs findVeneerLeaks from @pacyfist/veneer/testing in your browser, over this page's served HTML and every script it loaded.
What it costs you
These come from the same trick that does the protecting, so they can't be fixed without losing it. Weigh them before you protect anything.
Find in page
Ctrl+F can't find protected words, because the page doesn't contain them. You tried it above.
Copy and paste
Your human readers get gibberish when they copy a quote, too. Don't protect text people are meant to share, or opt in to readable copies (below).
Screen readers
Assistive tech reads the page's text, so it would say this out loud:
pWKvrov dWvzowr tKqzrB; NKv NdnzDdvzKN,
withA11y('hide') hides protected text from it while it is scrambled. That hides the text, it doesn't make it accessible: the Readable text switch at the top of this page turns scrambling off, and that is what gives these readers the words.
Opt in: copy readable text
With withReadableCopy(), copying a selection that includes protected text puts the words people see on the clipboard. Scripts reading the page still get gibberish. Select the sample, from the text above it if you like, and paste it somewhere.
Good use: article bodies, recipes, lyrics, product descriptions. Leave alone: menus, headings, form labels, and anything you want search engines to rank. Never: secrets. This is not encryption. Styling:text-transform and small-caps paint the wrong letters on protected text.
FAQ
Questions you're probably asking
Is this only for Angular?
Does it stop AI scrapers completely?
Is the key in the page?
What about screenshots and OCR?
Will it hurt my SEO?
Is it accessible?
What is readable mode?
What if the font fails to load?
Is it slow?
Does it work with server-side rendering?
Can I use any of it outside Angular?
What does the license mean for me?
Under the hood
For the curious
The full cipher table
The cipher this page uses, read back from its forged font by your browser. Every visitor of this build gets the same one. Letters swap only with letters, digits with digits, punctuation within groups the browser breaks lines at alike, and nothing maps to itself.
Every setting, on a test bench
The sample on the right has its own provideVeneer() and its own generated font. Change a setting and only it changes.
provideVeneer()
With the Latin presets, accented letters scramble too, swapping only with other accented letters, and you still read them. With DEFAULT_CHARSET alone they stay as they are. charsetFromText lists just the characters a text uses.
Pick the mismatched fallback, then turn on disabled, to see why the fallback should name the same typeface as the base font: it draws the text whenever protection is off.
Using the cipher outside Angular
The cipher has no framework in it. The same functions run in the browser, in an SSR process and in a plain Node script that pre-scrambles static content at build time.
node
import { buildScrambleMap, scrambleText } from '@pacyfist/veneer/core';const map = buildScrambleMap(12345);scrambleText('Hello, world', map);output round trip
qiBBA; kAoBj
Hello, world
Kerning and ligatures carry over
The generated font is your base font with only its character map replaced. Kerning (GPOS), ligatures and hinting carry over, so letter pairs tuck together exactly as in the original, even at display sizes.
Real bold and italic faces
With only the regular face, the browser fakes bold and italic by smearing and slanting it. withFaces() forges real bold and italic files from the same cipher, under the same family name.
Fine print about this demo
This site is prerendered to static files, so the page's own cipher is fixed at build time and every visitor sees the same one: the cipher table above. A server-rendered app draws one cipher per server process. The samples on the test bench and in "When it fails" run their own instances in your browser, each with a cipher of its own.
The demo sentences also ship readable inside this page's JavaScript bundle, because Angular compiles template text into it. That is true of any text written in an app's source; only content that arrives at runtime, from an API or a CMS, stays out of the bundle. The evidence for the HTML is the served page in "Try it yourself", and "Check this page for leaks" finds both.
Fonts: Roboto Regular (the page's font, a Roboto 2 build) is under the Apache License 2.0, fonts/Roboto-LICENSE.txt. The bold and italic faces are Roboto Classic 3 under the SIL Open Font License 1.1, fonts/faces/Roboto-OFL-LICENSE.txt. Both versions share the design and the advance widths, and each covers every character of the Latin presets.
Server-scrambled elements carry a data-veneer-ssr attribute. It tells the browser the text is already scrambled, so it is not scrambled a second time when the page hydrates.
Give the bots g!bb3r$h.
A free, open-source Angular library. Needs Angular 20, 21 or 22 and a .ttf, .otf or .woff font whose license allows modified versions.
npm install @pacyfist/veneerLicense: AGPL-3.0. If you use this on a public website, that site's source code has to be released under the same license. That's on purpose. Better a fast no here than a surprise later.