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>
what you see

A protected sample paragraph follows. It is stored scrambled and repaired on screen by a generated font.

Drag across the paragraph for bot vision.

Try it yourself

Don't take our word for it

Everything below happens in your browser, using your clipboard and your search box. Nothing is faked.

1. Copy it

A protected sentence you are invited to copy follows.

or select it and press Ctrl+C

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.

loading…

How it works

A secret code, and a font that cracks it

Think of a kid's decoder ring. The page holds the code, the font is the ring, and only your screen ever puts the two together.
  1. 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↓I
    e↓r
    l↓w
    l↓w
    o↓K
  2. 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. 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

Works with standalone components, signals, server-side rendering and hydration. No stylesheet to copy and no build plugin.
  1. 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
  2. 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.ts
    import { provideVeneer } from '@pacyfist/veneer';
    export const appConfig: ApplicationConfig = {
      providers: [
        provideVeneer({
          font: 'fonts/Roboto-Regular.ttf',
          fallbackFontFamily: "'Roboto', sans-serif",
        }),
      ],
    };
  3. Step 3

    Mark your text

    Add veneer to any element. That's it. People read it, and innerText gets noise.

    article.component.ts
    import { VeneerDirective } from '@pacyfist/veneer';
    @Component({
      imports: [VeneerDirective],
      template: `<p veneer>Scrapers get gibberish here.</p>`,
    })
    export class Article {}
  4. 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.ts
    import { 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 handler
    import { 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_HASHESexported valuecomputed 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.ts
provideVeneerServer({
  fonts: join(import.meta.dirname, '../browser'),
  secret: process.env['VENEER_SECRET'], // openssl rand -hex 32
  emitDir: '/srv/veneer-fonts', // storage every instance shares
});
// server.ts
app.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.ts
provideVeneerServer({
  fonts: join(import.meta.dirname, '../browser'),
  shouldProtect: (request, context) => !(context as { crawler?: unknown })?.crawler,
});
// server.ts: reverse and forward DNS, never the user agent alone
app.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.

A bot reads (this element's innerText)

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

If the decoder font can't be built, protected text quietly turns back into ordinary readable text. One exception: on a server-rendered page, if the browser cannot get the fonts or the forge code, the server's text stays scrambled and veneer reports VNR016. These buttons really break it.

Working normally

The decoder font loads and the text is protected.

The baseline, for comparison.

What monitoring receives (withStatusListener)

  1. 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

veneer makes lazy, bulk scraping pointless. It won't stop someone who is specifically after your content, and it doesn't pretend to.

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".

AttackerCostResult
(a) Crawler: one GET and an HTML parser5 lines, ~1 sBlockedGets only what the page prints in the clear outside protected elements.
(a') The same, also downloading the page's JavaScriptA few more linesSucceedsFor text written in the source, which ships in the bundle.
(b) Veneer-aware crawler: regex a seed, rebuild the cipher15 lines, <1 sBlockedThere is no seed in the page.
(c) Headless browser hooking FontFace56 lines, 1-2 sSucceedsOnce JavaScript runs, the forged font is in memory.
(d) Font-aware crawler: download both fonts, no JavaScript15 lines, 1-2 sSucceedsThe bar veneer sets: a font parser and two downloads.
Brute-forcing a seed~225 bitsBlockedInfeasible by default; trivial if the app fixes a seed.
Substitution solver on the ciphertext aloneOff the shelfSucceedsOn long passages: spaces and word lengths are kept.
Screenshot plus OCR or a vision modelRendering + inferenceSucceedsThe 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?
Yes. veneer is an Angular library for Angular 20, 21 and 22: the directive, pipe and provideVeneer() are Angular APIs, and it supports SSR and hydration. There is no React or Vue wrapper.
Does it stop AI scrapers completely?
No. It stops the cheap, bulk kind: crawlers that read your HTML without running JavaScript, scripts reading innerText, pasting a page into a chatbot. It does not stop a crawler that downloads your JavaScript bundles (text written in your templates is in there), a scraper that knows veneer and downloads the fonts (about 15 lines of code), a headless browser, or OCR. The threat model in the repository has the measured attack costs. It raises the cost of scraping; it doesn't make anything secret.
Is the key in the page?
No. With server rendering the server draws the cipher from a secure random source, with no seed at all, forges the font itself and writes only the font's URL into the HTML. A crawler that reads the HTML gets ciphertext and no key. The forged font is the key: anyone who downloads it and your normal font can compare them and undo the cipher, which is the bar veneer sets.
What about screenshots and OCR?
Not stopped. The pixels on screen are correct, so anything that reads pixels reads your text.
Will it hurt my SEO?
For the protected text, yes. Search engines index the server-rendered HTML, and for protected text that is gibberish. Protect article bodies, not the titles, headings and summaries you want to rank for, and keep meta descriptions to a summary. A server-rendered app can serve readable text to search crawlers it has verified by DNS, through the shouldProtect hook; a prerendered site like this one cannot.
Is it accessible?
Not while protection is on: screen readers, find-in-page, translation and copy-paste all get the scrambled characters, because that is the protection. The route to accessibility is readable mode: a toggle, like the Readable text button at the top of this page, that switches scrambling off for the visitor and is remembered. withA11y('hide') stops screen readers from reading gibberish aloud, but it hides the text rather than making it accessible. Both are opt-in. Leave links, labels and navigation unprotected.
What is readable mode?
A switch on the page that turns scrambling off for every protected element, for one visitor, and remembers the choice in their browser. The library ships it as a ready-made button. With it on, the page is ordinary text: screen readers, copy, search and translation all work.
What if the font fails to load?
It fails open: text turns readable again, an error with a stable code is logged, and a status listener can report it to your monitoring. With JavaScript disabled, visitors see the scrambled text instead of blank space. One case cannot fail open: on a server-rendered page, if the browser cannot get the base font, the forged font or the forge code (blocked, offline, a redeploy), the server-rendered text stays scrambled, because the readable text is not in that page. veneer retries the font requests once and then reports VNR016.
Is it slow?
Building the font takes a few milliseconds: it is your font with only its lookup table replaced, so kerning and ligatures stay. After that each character is one lookup. Visitors download your font (which they usually do anyway) and, on server-rendered pages, the forged font, about the same size.
Does it work with server-side rendering?
Yes, and that is where it matters most. Add provideVeneerServer() on the server: it forges the font and writes the scrambled text into the HTML. A server process uses one cipher for all its requests; a prerendered site like this one shares one cipher per build.
Can I use any of it outside Angular?
Only the low-level parts. The cipher and the font forge are plain functions in their own entry points, so a build script could pre-scramble static content. Everything else, including keeping text and font in sync, is the Angular layer.
What does the license mean for me?
It's AGPL-3.0. If you use it on a public website, that site's source code has to be available under the same license. That is intentional. Your font's license must also allow modified versions, because veneer makes a renamed copy of it; the README has a checklist.

Under the hood

For the curious

The nuts and bolts, live. Open whichever you like.
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

scrambleText

qiBBA; kAoBj

invertScrambleMap

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.

original
AV To
generated
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/veneer

License: 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.