CSS Decoy Fonts: How to Foil Web Scrapers and AI Bots with Typography

Picture this: You’ve spent months building a proprietary dataset, curating financial data, or writing high-quality content. You deploy your web app, and within hours, your server logs are flooded with traffic. It’s not eager customers; it’s a fleet of headless browsers and AI scrapers vacuuming up your intellectual property. They bypass your rate limits with residential proxies, solve your CAPTCHAs using LLMs, and ignore your robots.txt entirely.

How do you stop them? Standard defense-in-depth security says we should block IP ranges or use behavioral analysis. But what if we could poison the data at the presentation layer? What if the text looked perfectly normal to a human eye, but read as complete gibberish to an automated scraper?

Enter the fascinating world of Decoy Fonts (often referred to as font scrambling or SVG font obfuscation). This technique is trending on Hacker News for a reason: it’s a brilliant, low-overhead way to protect sensitive web data by weaponizing the browser’s rendering engine against scrapers. Today, we’re going to dive deep into how decoy fonts work, write a custom Node.js script to generate one, and look at the real-world trade-offs of this security pattern.

The Core Concept: Visual vs. Semantic Representation

To understand why decoy fonts work, we have to look at how modern browsers render text. Normally, there is a 1:1 mapping between a character’s unicode value (semantic meaning) and its glyph (visual representation):

  • Unicode character: U+0041 ("A")
  • Font Glyph: The visual shape consisting of two diagonal lines and a crossbar ("A").

When a web scraper parses your site using tools like Puppeteer, Playwright, or Beautiful Soup, it reads the DOM’s text content. It pulls the raw Unicode values directly from the HTML elements. It doesn't care how they look on screen.

A Decoy Font disrupts this mapping. We dynamically shuffle the character map (cmap) table of a web font. We map the visual glyph of "A" to the unicode value of "X", the glyph of "B" to "Q", and so on.

If we want to display the word "BANK" on our website, we don't write <span>BANK</span> in our HTML. Instead, we write the scrambled equivalent—let's say, <span>WPLO</span>—and apply our custom decoy font to that element.

To a human visitor, the browser loads the font, translates WPLO back to their respective visual glyphs, and renders BANK perfectly. To a scraper extracting the inner text, your page contains nothing but scrambled nonsense like WPLO.

Step-by-Step: Implementing a Decoy Font

Let's build a functional prototype of this technique. We will write a Node.js utility that takes a sensitive string, scrambles it, and generates a subsetted, obfuscated Web Open Font Format (WOFF/WOFF2) file containing only the glyphs we need mapped to random Unicode points.

Step 1: Setting up the dependencies

We’ll use the opentype.js library to manipulate font files at the binary level. First, install the package:

npm install opentype.js

Step 2: The Font Scrambling Script

Here is a complete Node.js script that loads a standard TrueType font (like Arial or Roboto), shuffles the Unicode mappings for a target string, and outputs both the obfuscated HTML code and a custom scrambled font file.

const opentype = require('opentype.js');
const fs = require('fs');

// The sensitive data we want to protect
const sensitiveData = "API_KEY_9923_SECURE";

// Generate a randomized mapping for our character set
function generateScrambleMap(uniqueChars) {
    const chars = Array.from(uniqueChars);
    const shuffled = [...chars].sort(() => Math.random() - 0.5);
    const map = {};
    const reverseMap = {};

    chars.forEach((char, index) => {
        map[char] = shuffled[index];
        reverseMap[shuffled[index]] = char;
    });

    return { map, reverseMap };
}

// Load a base font and generate the decoy font
opentype.load('./Roboto-Regular.ttf', (err, font) => {
    if (err) {
        console.error('Could not load font: ' + err);
        return;
    }

    const uniqueChars = new Set(sensitiveData);
    const { map, reverseMap } = generateScrambleMap(uniqueChars);

    // Scramble our input string based on the map
    const scrambledString = sensitiveData.split('').map(char => map[char]).join('');
    console.log(`\nOriginal Data: ${sensitiveData}`);
    console.log(`Scrambled DOM Text: ${scrambledString}`);

    // Create a new glyph list for our custom font
    const newGlyphs = [
        font.glyphs.get(0) // Keep the undefined glyph (.notdef) as index 0
    ];

    // For every character in our scrambled string, map the visual glyph
    // of the original character to the unicode value of the scrambled character
    uniqueChars.forEach(originalChar => {
        const scrambledChar = map[originalChar];
        const originalGlyph = font.charToGlyph(originalChar);
        
        // Clone the glyph and assign it the scrambled character's unicode point
        const decoyGlyph = new opentype.Glyph({
            name: originalGlyph.name,
            unicode: scrambledChar.charCodeAt(0),
            path: originalGlyph.path,
            xMin: originalGlyph.xMin,
            yMin: originalGlyph.yMin,
            xMax: originalGlyph.xMax,
            yMax: originalGlyph.yMax,
            advanceWidth: originalGlyph.advanceWidth
        });

        newGlyphs.push(decoyGlyph);
    });

    // Construct the new font
    const decoyFont = new opentype.Font({
        familyName: 'DecoyFont',
        styleName: 'Regular',
        unitsPerEm: font.unitsPerEm,
        ascender: font.ascender,
        descender: font.descender,
        glyphs: newGlyphs
    });

    // Write the font file to disk
    const buffer = decoyFont.toBuffer();
    fs.writeFileSync('./decoy-font.ttf', Buffer.from(buffer));
    console.log("Decoy font successfully generated: ./decoy-font.ttf\n");
    
    // Output the HTML/CSS template
    generateHTML(scrambledString);
});

function generateHTML(scrambledText) {
    const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        @font-face {
            font-family: 'MyDecoyFont';
            src: url('./decoy-font.ttf') format('truetype');
        }
        .protected-data {
            font-family: 'MyDecoyFont', sans-serif;
            font-size: 24px;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <h1>Welcome to the Secure Portal</h1>
    <p>The key below is readable by humans, but gibberish to crawlers:</p>
    <div class="protected-data">${scrambledText}</div>
</body>
</html>`;
    fs.writeFileSync('./index.html', htmlContent);
}

How Scrapers Fall Into the Trap

When this page is rendered in a normal browser, the user sees API_KEY_9923_SECURE. However, let’s see what happens if a scraper tries to extract the data using a standard Python scraping script:

import requests
from bs4 import BeautifulSoup

response = requests.get("http://localhost:3000/index.html")
soup = BeautifulSoup(response.text, 'html.parser')
key = soup.find(class_="protected-data").text

print(f"Extracted Key: {key}")
# Output: Extracted Key: UI_P_FWS_0012_HQDURA (Gibberish!)

Because the scraper extracts raw HTML or DOM nodes without executing a complex, resource-heavy Optical Character Recognition (OCR) pipeline on screenshots of the page, it gets completely poisoned data. If they try to feed this key into an API or an LLM, it will fail instantly.

The Engineering Trade-Offs: When NOT to use Decoy Fonts

As slick as this technique is, it is not a silver bullet. Applying decoy fonts introduces several technical hurdles that you must evaluate before shipping this to production.

1. Accessibility (a11y) Violations

This is the biggest drawback. Screen readers do not render fonts visually; they read the underlying Unicode stream. A blind user visiting your site with a screen reader will hear "UI P FWS zero zero one two HQDURA" instead of "API KEY...". If you use this technique on content critical for accessibility, you are directly violating WCAG guidelines and locked out of compliance standards (like Section 508).

2. Copy-and-Paste is Broken

If a user tries to highlight the scrambled text on your website, copy it to their clipboard, and paste it into a notepad, they will paste the scrambled Unicode characters, not the visually rendered text. If your web app displays information that users legitimately need to copy (like tracking numbers, code snippets, or addresses), this technique will severely degrade your UX.

3. Performance and Font Subsetting

Generating a global decoy font for an entire alphabet can result in large file sizes. To mitigate this, you should dynamically subset your decoy fonts (like we did in our Node.js script), packaging only the characters used on that specific page. This requires on-the-fly font generation on your server or CDN edge nodes, which adds CPU overhead to your rendering pipeline.

4. Advanced Scrapers with OCR

If your data is valuable enough, scrapers will run headless browsers (like Chromium via Playwright), take a screenshot of the rendered element, and pass it through an OCR engine like Tesseract or a multimodal LLM (like GPT-4o). While this is significantly more expensive and slower for the scraper, it bypasses decoy fonts entirely.

Best Practices for Implementing Decoy Fonts

If you decide that decoy fonts are right for your specific use case (e.g., hiding pricing tables, proprietary analytics dashboards, or preventing high-frequency email scraping), keep these implementation strategies in mind:

  • Keep it dynamic: Change the character mapping on every single page load. If you use a static decoy font, scrapers can easily map the scrambled characters back to their original counterparts using a simple translation dictionary.
  • Use CSS user-select: none;: Since copying the text yields gibberish anyway, prevent user frustration by disabling text selection on scrambled elements. This signals to the user that the element behaves differently.
  • Combine with honeypots: Serve the scrambled text alongside hidden, unscrambled elements (e.g., <span style="display:none;">) containing bait data. When a scraper extracts the bait, immediately flag and block their IP.

Wrapping Up

Decoy fonts represent a creative shift in the web scraping arms race. Instead of trying to block the client at the network layer (which is becoming increasingly difficult in the age of distributed AI bots), we manipulate the presentation layer to feed automated agents useless data while keeping the user experience intact for visual users.

Have you ever encountered font scrambling in the wild? Or perhaps you've implemented a similar obfuscation trick to protect your frontend data? Let me know in the comments below, or hit me up on Twitter/X at @sysseder!

Happy hacking!

Post a Comment

Previous Post Next Post