Obfuscating Contact Data on a Static Site
German law (§ 5 TMG) requires a publicly accessible imprint with a real name, address, and working email address. That’s a gift to spam harvesters: a legal obligation to publish exactly the data they’re looking for.
This post covers the approach I settled on — combining two independent techniques to protect that data on a static site without sacrificing usability.
The Problem
A static site has no server-side rendering to help. The HTML is delivered as-is. Any email address that appears in the source can be found by a simple regex scan.
The most common approach — <a href="mailto:hey@example.com"> — is essentially a harvester’s dream. No JavaScript required, no interaction needed.
First Layer: Base64 + JavaScript
The first technique moves the actual contact data out of the HTML source entirely. Instead of rendering the text directly, the element is left empty and a data-obf attribute carries the content as Base64:
<span data-obf="cmVudGxBIG5haXJkQQ=="></span>
A small script decodes and injects it at runtime:
document.querySelectorAll('[data-obf]').forEach(el => {
el.textContent = atob(el.dataset.obf);
});
For links, data-obf-href works the same way — the mailto: is never in the HTML:
<a data-obf-href="bWFpbHRvOmhleUBleGFtcGxlLmNvbQ=="></a>
This stops static HTML scrapers cold. The source contains only Base64, and the decoded text only exists in the DOM after JavaScript runs.
What it doesn’t protect: copy-paste. Once the script runs and the real text lands in the DOM, selecting and copying gives the actual address.
Second Layer: CSS RTL Reversal
The second technique addresses copy-paste. The idea comes from a common trick used by privacy-focused blogs: store the text reversed in the DOM, then use CSS to flip it back visually.
.r {
direction: rtl;
unicode-bidi: bidi-override;
}
With unicode-bidi: bidi-override, the browser renders characters strictly right-to-left. So the string rentlA nairdA stored in the DOM displays as Adrian Altner. When a visitor copies the text, they get rentlA nairdA — not the real name.
Combining Both
The two techniques compose cleanly. The data-obf attribute stores a Base64-encoded reversed string. JavaScript decodes it and writes the reversed text into the DOM. CSS then renders it visually correct.
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span>
The flow:
- Static HTML: empty element, opaque Base64 attribute
- After JS:
rentlA nairdAin the DOM - After CSS: displays as
Adrian Altner - On copy-paste:
rentlA nairdAlands in the clipboard
A static scraper sees nothing. A JS-capable scraper sees reversed text. A human copying sees the reversed string.
Generating the Reversed Strings
For plain text, reversing is straightforward. For email addresses, the @ and . need placeholder substitutions so they survive the round-trip legibly. The placeholders also need to account for another browser behavior: bidi mirroring.
Bracket characters like {, }, (, ) are Unicode bidi-mirror pairs. When rendered in RTL context, the browser swaps them: { becomes } and vice versa. This means that to display {at}, the stored string must contain {ta} — the reversed character order of {at} — because the browser mirrors the braces as part of the RTL rendering:
- Stored:
{ta}→ reversed char order:}at{→ bidi-mirrored:{at}✓ - Stored:
}ta{→ reversed char order:{at}→ bidi-mirrored:}at{✗
The same applies to {dot}:
- Stored:
{tod}→ reversed:}dot{→ mirrored:{dot}✓
So to display hey{at}adrian-altner{dot}com, the stored string is:
moc{tod}rentla-nairda{ta}yeh
A small Node script generates all the values for address fields:
const obfuscate = s =>
s.split('').reverse().join('')
.replace(/@/g, '{ta}')
.replace(/\./g, '{tod}');
Then Base64-encode the result for the data-obf attribute.
What This Protects (and What It Doesn’t)
| Threat | Protected |
|---|---|
| Static HTML scrapers (most spam bots) | ✅ |
| Regex email harvesters | ✅ |
| Copy-paste by visitors | ✅ (reversed text in clipboard) |
| Headless browsers running JS (Puppeteer) | ⚠️ reversed text, no plain email |
| Someone inspecting the decoded DOM | ❌ |
| Manual reading by a human | ❌ |
The last two cases are unavoidable: if the data is visible to a human, a determined human can get it. The goal isn’t perfect protection — it’s raising the bar high enough to stop the automated tools responsible for the vast majority of harvested-address spam.
For a public imprint on a personal blog, this is a reasonable trade-off.