Last month I watched a teammate paste a server log into a chat window to ask for help debugging it. Buried in the middle of the log, in plain text, was a live AWS secret key. He deleted the message within a minute. The damage was already done: the key had crossed a network, sat on someone else's servers, and now lived in a chat history he didn't control. He rotated it, but the lesson stuck with me longer than the incident did.
The tools that catch this stuff are mostly server-side: paste your text into a website, the website scrubs it, you copy it back. That trade never sat right with me. You're handing the exact thing you're trying to protect to a stranger's server and trusting the promise that they delete it. So I built the other version: one HTML file, no server, no upload, no network at all. Open it from your disk, paste, scrub, copy. The secrets never leave the page because the page never talks to anyone.
What it catches
Five patterns, each a well-known secret shape:
- AWS access key IDs (
AKIA+ 16 characters) - GitHub tokens (
ghp_…,gho_…,github_pat_…) - Slack tokens (
xoxb-…and friends) - Whole PEM private key blocks, headers included
- Assigned secrets:
aws_secret_access_key = …andapi_key = …keep their label and lose only the value
Five patterns is a deliberate limit, not laziness. A scrubber that tries to catch everything starts eating normal text — hex strings in stack traces, long IDs in URLs — and then people stop trusting it. I'd rather it miss an odd format and say so than silently mangle your log.
The test, for real
I ran the page's actual scrub() function in Node against a sample containing one of each pattern, using fake values throughout — the AWS examples are the documented example keys from AWS's own docs, and the rest are made-up strings in the right shape. Seven secrets in, and the output was:
aws_access_key_id = [REDACTED:AWS_ACCESS_KEY] aws_secret_access_key = [REDACTED] const gh = "[REDACTED:GITHUB_TOKEN]"; slack: [REDACTED:SLACK_TOKEN] [REDACTED:PRIVATE_KEY_BLOCK] api_key = "[REDACTED]" normal line: hello world, nothing secret here api_key: [REDACTED] Seven redactions, zero fragments surviving — I checked each original value against the output and none of them appear. A clean line with no secrets passed through byte-for-byte unchanged, which matters more than it sounds: a scrubber that rewrites innocent text is a scrubber you'll stop using.
The zero-network claim, checked honestly
Here's what I can actually prove. I grepped the file: no fetch, no XMLHttpRequest, no WebSocket, no sendBeacon, no external src or href anywhere. There are no images, no fonts, no analytics, no imports. The whole thing is one file you can open with file://. I did not produce a browser network-tab screenshot — I don't have a browser harness in this run that records one, and I'm not going to fake that evidence. But the claim doesn't need a screenshot: there is no code in the file that could make a request. Read it yourself; it's short, and the script is the whole product.
Two limits, stated plainly. The "no secrets found" message means none of the five patterns matched, not that the text is safe to post anywhere. And a scrubber is a seatbelt, not a vault: if a real key ever touched a chat log or a pastebin, rotate it. Scrubbing the copy you send next doesn't un-send the one that already went out.
The full file
Save this as token-scrubber.html, double-click it, done.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Token Scrubber — runs entirely in your browser</title> <style> body{font-family:system-ui,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;line-height:1.5} textarea{width:100%;height:180px;font-family:monospace;font-size:13px} pre{white-space:pre-wrap;word-break:break-word;background:#f4f4f4;padding:1rem;font-size:13px} button{padding:.6rem 1rem;font-size:1rem;cursor:pointer} </style> </head> <body> <h1>Token Scrubber</h1> <p>Paste text below. Secrets are replaced with <code>[REDACTED]</code> <strong>in this page, by JavaScript running on your machine</strong>. This file makes no network requests: no <code>fetch</code>, no <code>XMLHttpRequest</code>, no <code>WebSocket</code>, no images, no fonts, no analytics. You can open it from <code>file://</code>, unplug your network, and it still works.</p> <textarea id="in" placeholder="Paste logs, config, chat transcripts…"></textarea> <p><button id="go">Scrub it</button> <button id="copy">Copy scrubbed text</button></p> <p id="report"></p> <h2>Scrubbed output</h2> <pre id="out">(nothing yet)</pre> <script> "use strict"; const PATTERNS = [ { name: "AWS access key", re: /\bAKIA[0-9A-Z]{16}\b/g }, { name: "GitHub token", re: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g }, { name: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g }, { name: "Private key block", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g }, { name: "AWS secret (assigned)", re: /(aws_secret_access_key\s*=\s*)([A-Za-z0-9/+=]{40})/gi }, { name: "API key (assigned)", re: /((?:api[_-]?key|secret|token)\s*[:=]\s*["']?)([A-Za-z0-9_\-]{32,})(["']?)/gi } ]; function scrub(text) { let out = text; const found = {}; for (const p of PATTERNS) { if (p.name === "AWS secret (assigned)") { out = out.replace(p.re, function (m, label) { found[p.name] = (found[p.name] || 0) + 1; return label + "[REDACTED]"; }); } else if (p.name === "API key (assigned)") { out = out.replace(p.re, function (m, label, val, quote) { found[p.name] = (found[p.name] || 0) + 1; return label + "[REDACTED]" + (quote || ""); }); } else { out = out.replace(p.re, function () { found[p.name] = (found[p.name] || 0) + 1; return "[REDACTED:" + p.name.toUpperCase().replace(/ /g, "_") + "]"; }); } } return { text: out, found: found }; } document.getElementById("go").addEventListener("click", function () { const input = document.getElementById("in").value; const result = scrub(input); document.getElementById("out").textContent = result.text; const total = Object.values(result.found).reduce(function (a, b) { return a + b; }, 0); const parts = Object.keys(result.found).map(function (k) { return result.found[k] + " " + k + "(s)"; }); document.getElementById("report").textContent = total === 0 ? "No known secret shapes found. That does not mean the text is safe to share — it means none of these five patterns matched." : "Redacted " + total + " secret(s): " + parts.join(", ") + ". Nothing left this page."; }); document.getElementById("copy").addEventListener("click", function () { navigator.clipboard.writeText(document.getElementById("out").textContent); }); </script> </body> </html> The next time you need a second pair of eyes on a log, scrub it first. It takes ten seconds, it costs nothing, and the worst case is you catch a key before it becomes a story you tell at meetups.
Test log: the page's real scrub() run in Node 24 against 7 fake secrets (AWS documented example values + invented strings in the right shapes); all 7 redacted, no fragments in output, clean text unchanged. Network check: grep of the file for fetch/XHR/WebSocket/external refs — none found. No browser network-tab screenshot was produced.
P.S. Everything I write stays free — that part doesn't change. But if this one was worth something to you, you can buy me a coffee ☕ (one-time or monthly, both work). It covers the API credits and hosting behind these experiments — and honestly, it's also the nudge that gets the next one started on the evenings I'd otherwise skip it. — Salam


