Hide email addresses from scrapers without breaking the
mailto:link for humans. ~1 KB, zero runtime dependencies, works in any HTML page.
Live demo → · npm · Report a bug
Put a mailto: link on a public page and a scraper will harvest it within hours. mailhider stores the address reversed in data-* attributes and reassembles it client-side, so a regex over your HTML source finds nothing while real visitors get a normal clickable link. Optional click-to-reveal mode raises the cost further by withholding the address until a real user interacts with the element.
It works three ways: a <script> tag for static HTML, an ES module for build steps, and a CLI for one-off snippets.
- Install
- Quick start
- Threat model — what this actually stops
- Compared to other approaches
- How to hide
- CLI
- API reference
- Framework recipes
- SPA navigation support
- Release history
- License
npm i mailhiderOr skip the install and load the runtime from a CDN — see Quick start.
Drop the obfuscated span into your HTML and include the decoder script once anywhere on the page.
<span class="mh-email" data-u="olleh" data-d="moc.elpmaxe">
<noscript>Enable JavaScript to view email.</noscript>
</span>
<script src="https://unpkg.com/mailhider/dist/browser.js"></script>data-u is the username (hello) reversed, data-d is the domain (example.com) reversed. The decoder reverses them at runtime and replaces the span with <a href="mailto:hello@example.com">hello@example.com</a>.
To generate the snippet from an email address, use the CLI or obfuscate().
Email obfuscation is a cost-raising measure, not a cryptographic one. Here is what mailhider actually does against the scrapers in the wild:
| Scraper type | Reverse mode (default) | Click-to-reveal mode |
|---|---|---|
curl + regex |
✅ defeated | ✅ defeated |
wget mirroring |
✅ defeated | ✅ defeated |
| Generic HTML-only crawlers (the vast majority) | ✅ defeated | ✅ defeated |
| Headless Chrome / Playwright with no interaction | ✅ defeated | ✅ defeated |
| Headless browser that clicks every span on every page | ❌ readable from DOM | ✅ defeated (no click triggered programmatically) |
| Targeted human attacker reading your source code | ❌ trivially reversed | ❌ trivially reversed |
Reverse mode is the right default. It stops the 95%+ of scrapers that don't execute JavaScript, and it stops headless browsers that just render the page without clicking. Click mode raises the cost further at the price of one user interaction.
What this library does not do:
- Defeat a targeted attacker. The decoder is open source and the encoding is reversible by design.
- Cover off-page exposure. If your address still appears in
feed.xml, JSON-LD blocks,sitemap.xml, or Open Graph tags, scrapers will find it there. Remove or blank those at the source. - Cover image-rendered or PDF-attached emails. For maximum security, render the address as an image server-side.
If overclaim makes you nervous, the live demo runs a regex scan over its own source bytes so you can see exactly what a naive scraper sees.
| Approach | Bundle | No-JS readable | Stops headless browsers | Notes |
|---|---|---|---|---|
Plain mailto: link |
0 B | ✅ | — | Harvested within hours. |
HTML entity encoding (h...) |
0 B | ✅ | ❌ | Defeated by any scraper from the last decade. |
[at] / [dot] text obfuscation |
0 B | ✅ | ❌ | Matched by common scraper regex variants. |
| Cloudflare Email Protection | ~1 KB inline | ❌ | ✅ | Cloudflare-only, breaks outside their proxy. |
| mailhider reverse mode | ~1 KB | optional | ✅ | Works anywhere static HTML works. |
| mailhider click mode | ~1 KB | optional | ✅ (stronger) | One extra user click in exchange for more cost to bots. |
| Server-rendered image of the email | ~2 KB image | ❌ | ✅ | Inaccessible. No mailto:. Bad UX. |
| Contact form (no email shown) | varies | ❌ | ✅ | Different UX trade-off; complementary, not a replacement. |
Generate the obfuscated HTML from JavaScript or TypeScript.
import { obfuscate } from 'mailhider';
const html = obfuscate('hello@example.com');
// <span class="mh-email" data-u="olleh" data-d="moc.elpmaxe"><noscript>Enable JavaScript to view email.</noscript></span>Use the output anywhere you'd write static HTML: server-rendered templates, static site generators, build scripts.
The email is not assembled until the visitor clicks. This defeats non-interactive JS-executing scrapers (headless Chrome, Playwright) because they don't simulate clicks on every span on every page they crawl. A scraper that does click every span on every page can still extract it — see the threat-model table above.
const html = obfuscate('hello@example.com', { mode: 'click' });
// <span class="mh-email" data-u="olleh" data-d="moc.elpmaxe" data-mh-mode="click">Show email</span>The default label in click mode is Show email. Override with the label option. After the click, the runtime assembles the address, replaces the span with a real anchor, and immediately triggers it — so the visitor's mail client opens with one click, not two.
The visible text shown to humans before the JS-decoded address replaces it.
const html = obfuscate('hello@example.com', { label: 'Email us' });
// <span class="mh-email" data-u="olleh" data-d="moc.elpmaxe">Email us</span>Pre-fill the subject line of the assembled mailto: link.
const html = obfuscate('sales@example.com', { subject: 'Pricing inquiry' });
// <span class="mh-email" data-u="selas" data-d="moc.elpmaxe" data-mh-subject="Pricing inquiry"><noscript>Enable JavaScript to view email.</noscript></span>The runtime decoder assembles mailto:sales@example.com?subject=Pricing%20inquiry.
With bracketFallback: true, the visible text uses the user[at]domain[dot]tld form instead of an empty <noscript> fallback. This lets no-JS visitors read the address — at the cost of being readable by scrapers that match [at] / [dot] variants. Only use this if no-JS reachability matters to you more than scraper resistance.
const html = obfuscate('info@example.com', { bracketFallback: true });
// <span class="mh-email" data-u="ofni" data-d="moc.elpmaxe">info[at]example[dot]com</span>To preserve existing button or link styling, write the anchor yourself and let the decoder fill in the href. Use a <span class="mh-email-display"> slot to control where the assembled address text goes.
<a class="mh-email button" data-u="olleh" data-d="moc.elpmaxe">
<span>Contact →</span>
<span class="mh-email-display"></span>
</a>The decoder sets the anchor's href to mailto:hello@example.com and fills the .mh-email-display span with the assembled address.
If mh-email conflicts with existing styles, change the wrapper class.
const html = obfuscate('hello@example.com', { className: 'my-email' });
// <span class="my-email" data-u="olleh" data-d="moc.elpmaxe">...Pass the matching class to hydrate(root, className) at runtime.
npx mailhider <email> [options]
Options:
--mode=reverse|click Obfuscation mode (default: reverse)
--label=TEXT Visible text (default: empty span with noscript fallback)
--bracket-fallback Use user[at]domain[dot]tld as the visible text
--subject=TEXT mailto subject line
--class=NAME CSS class on the wrapper (default: mh-email)
--script Also print the runtime <script> tag
-h, --help Show help
-V, --version Show version
Examples:
npx mailhider hello@example.com
npx mailhider support@example.com --mode=click --label="Email support"
npx mailhider sales@yourbusiness.com --subject="Pricing inquiry" --script
npx mailhider info@example.com --bracket-fallbackimport { obfuscate, decode, hydrate, runtimeScript } from 'mailhider';
obfuscate(email: string, options?: ObfuscateOptions): string;
decode(html: string): string | null;
hydrate(root?: Document | HTMLElement, className?: string): number;
runtimeScript: string;Returns the obfuscated HTML snippet.
interface ObfuscateOptions {
mode?: 'reverse' | 'click'; // Default: 'reverse'
label?: string; // Visible text. Default: empty + <noscript>
bracketFallback?: boolean; // Use user[at]domain[dot]tld as the visible text
subject?: string; // mailto subject
className?: string; // Default: 'mh-email'
}Recovers the email from an obfuscated snippet. Mostly useful for tests. Returns null if no decodable span is found.
decode(obfuscate('hello@example.com'));
// 'hello@example.com'Decodes every matching element under root and returns the count decoded. Default root is document, default className is mh-email. Idempotent — re-running is a no-op for already-decoded elements.
The decoder runtime as a vanilla-JS IIFE string. Drop into a <script> tag for sites without a build step.
<!-- _includes/email.html -->
<span class="mh-email" data-u="{{ include.u }}" data-d="{{ include.d }}">
<noscript>Enable JavaScript to view email.</noscript>
</span>Add the decoder once in your layout:
<script src="https://unpkg.com/mailhider/dist/browser.js"></script>{{/* layouts/shortcodes/email.html */}}
<span class="mh-email" data-u="{{ .Get "u" }}" data-d="{{ .Get "d" }}">
<noscript>Enable JavaScript to view email.</noscript>
</span>{{< email u="olleh" d="moc.elpmaxe" >}}
obfuscate() is a plain function. Call it in any render path and output the returned HTML.
import { obfuscate } from 'mailhider';
const html = obfuscate('hello@example.com');The decoder re-runs automatically on these client-side navigation events:
- Hydejack:
hy-push-state-load - Astro:
astro:page-load - Turbo (Rails):
turbo:load
For frameworks not in the list, call hydrate() yourself after navigation.
- Fix
mailhider/runtimesubpath import on CommonJS consumers (Node 18–20). Previouslyrequire('mailhider/runtime')would throwERR_REQUIRE_ESM. - Conditional
typesresolution across all subpath exports somoduleResolution: "nodenext"projects pick the correct.d.ts/.d.ctsfor their module system. - Stricter email validation in
obfuscate(). Inputs with whitespace, newlines, control characters, multiple@signs, or missing TLD now throw with a clear error. The previous validation only checked for one non-edge@. hydrate()now usesgetElementsByClassNameinstead ofquerySelectorAll('.${className}'), so arbitrary class strings (including those with CSS metacharacters) no longer break selector parsing.- Ship readable, unminified source in the npm tarball. Following npm best practice, consumers' bundlers handle minification downstream; the unminified source keeps the package auditable by security tools and humans reading
node_modules/. End-user bundles are unaffected. - Removed the CDN URL from
src/browser.tsJSDoc to keep external URL strings out of shipped JavaScript. CDN usage is documented in the README only. - Disabled source-map generation in shipped builds. Source-map comments in the dist files previously referenced
.mapfiles that were never published. - Tightened README and demo wording: replaced "defeats most JS-executing scrapers" with "defeats non-interactive JS-executing scrapers" to be precise about the threat model.
- Initial release
obfuscate()build-time encoderhydrate()runtime decoder- Click-to-reveal mode
- CLI with
--mode,--label,--subject,--bracket-fallback,--class,--script - Auto-rehydration on Hydejack
hy-push-state-load, Astroastro:page-load, Turboturbo:load
MIT © Koh You Liang