The Bearer Token I Almost Shipped
6 min read
I built a privacy redactor for my session telemetry pipeline. It was supposed to strip Authorization headers, Bearer tokens, API keys, JWTs, and other secrets out of every conversation transcript before any of it landed in my new lake database. Test it on a few examples, ship it, walk away.
I almost shipped a privacy bug that would have written live API keys into a searchable database in cleartext.
A code review caught it in five minutes.
The Setup
The new lake is supposed to ingest every turn of every Claude Code conversation I've ever run — full text, tool calls, tool outputs, token usage, the works. About 38,000 turns at the time of the backfill. Some of those turns would inevitably contain secrets: API keys printed by accident, environment variables echoed for debugging, full HTTP request logs with Authorization headers from external services.
You don't get to opt out of redaction. The lake either redacts at the door or it becomes a secret-storage liability the moment the first secret lands.
So I wrote a redactor. A list of regex patterns, each one finding a particular secret shape and substituting [REDACTED]. Bearer tokens. sk- prefix tokens (OpenAI / Anthropic). xoxb- and xoxp- (Slack). JWT tokens (eyJ...). Long hex strings. Authorization headers. Password fields.
I tested it on a handful of canonical strings. It looked right. I committed.
The Bug
Here's the relevant pattern, exactly as it shipped to the first review:
re.compile(r"(?i)\b(authorization|x-api-key|x-auth-token|cookie|set-cookie)\s*[:=]\s*\S+")
Spot the bug.
I'll wait.
The pattern matches Authorization: followed by any whitespace, then \S+ — one or more non-whitespace characters. The replacement is \1: [REDACTED].
Take this real input:
Authorization: Bearer sk-1234567890abcdefghij
Run the pattern against it. The \S+ is greedy in positive terms — it grabs all consecutive non-whitespace — but it stops at the first whitespace. So it matches:
Authorization:(the header name)(whitespace)Bearer(one non-whitespace run)
Then it stops. The space before sk-1234... ends the match.
Replacement: Authorization: [REDACTED]. Plus the trailing, unmatched part of the line: sk-1234567890abcdefghij.
Final output:
Authorization: [REDACTED] sk-1234567890abcdefghij
The literal token, intact, in the output. The header label is gone, which makes the leak harder to grep for, which makes it worse — search for "Bearer sk-" and you'd find it. Search for "Authorization: Bearer" and you wouldn't.
If I'd shipped this, I'd have spent the afternoon writing 38,000 of my own conversation turns into a Postgres database, and a small but non-zero number of those rows would have contained live API keys, in cleartext, in a database I built specifically to make raw transcripts searchable.
I want to emphasize: I had tested the redactor. I had run it against several canonical strings. The strings I tested with weren't the same shape as the actual data. My tests gave me false confidence.
The Catch
The code review was an automated pass — a Code Reviewer subagent reading the diff, looking for security and correctness issues, returning a structured list of blockers, warnings, and suggestions.
It caught the bug as B1: BLOCKER, with a precise reproduction:
The first pattern matches
Authorization: ... \S+, but\S+stops at the first whitespace. On the canonicalAuthorization: Bearer sk-xxxshape, it consumes onlyBearerand leaves the secret in cleartext, then rewrites the line toAuthorization: [REDACTED] sk-xxx. Verified by repro.
It also recommended the fix: change \S+ to [^\r\n]+ so the entire rest of the line gets consumed, and reorder the patterns so token-shape matches (bearer\s+..., sk-..., JWT, etc.) run first, before the header pattern can catch them.
That's a five-minute fix. I made it, re-tested with seven canonical cases including the actual MCP bearer token I'm using right now, and shipped.
What I Take From This
A few things I was already supposed to know but feel sharper now.
Test against shapes that look like your actual data, not toy examples. The strings I tested with were short and well-formed. A real Authorization: Bearer ... header with a sixty-four-character token is structurally different from Authorization: Bearer xyz. My toy examples gave the regex an easy time. The real shape was the one that revealed the bug.
Pattern order matters more than you think. With independent patterns, you assume each one acts in isolation. But a regex pipeline runs them sequentially, and an earlier pattern's output is the next pattern's input. A header pattern that "matches and replaces" the start of a line can hide a token-shape pattern from ever running on the same content. Token-shape matches need to come first specifically because they don't depend on context — they catch the secret regardless of where it sits.
Defense-in-depth applies even to your defenses. If the header pattern had failed completely, the token-shape pattern would have caught the sk- token. If the token-shape pattern had also failed, the long-hex pattern would have caught a 64-character hex string. Layered patterns mean a single bug doesn't equal a leak. The fix wasn't just to repair the broken pattern — it was to put the layers in an order that made any one of them sufficient.
Code review is cheap insurance. I run an LLM-based reviewer on every PR. Most of the time it returns minor suggestions and one or two warnings. Once in a while it catches something I'd genuinely have shipped. That's the whole point. The cost is negligible. The save is non-negligible. There is no version of this calculation where the reviewer pays for itself less than five times over in a year.
The code review wasn't doing my job for me. It was doing the second version of my job — the one I should have done before submitting and didn't, because I was ready to walk away.
That's what reviewers are for. Even — maybe especially — when they're machines.
Now
The redactor is fixed. The lake holds 38,925 redacted turns. The first thing I did after merging was grep the lake for Bearer (capital B, trailing space) — zero hits. Then sk- — zero hits except the literal pattern token I used in test fixtures. Then xoxb- — zero. Then eyJ — zero JWT-shaped strings.
It works now. The five-minute review save was worth the whole afternoon.
I'll keep running the reviewer.
This is part 8 of a series on building autonomous AI infrastructure on consumer hardware. If you're shipping data pipelines that touch user content, run a reviewer. The minor cost saves the major incident.