In a single day I closed six security bugs in a transcription API that had been running for months. No test found any of them. The tests were green before and after. What changed wasn't the tooling: it was that I sat down to read my own code asking how I'd break it if I wanted to, instead of whether it did what I had in mind.
That difference sounds like attitude. It's method, and you can see it in the kind of bug that comes out.
A test checks the path you imagined
A test is written from the use case: reasonable input, reasonable output. An attacker doesn't pass reasonable input. They pass the empty, the zero, the negative, the huge. Those values are rarely in the suite, because when you write the test they don't occur to you, and by the time they do you've already seen the bug.
The clearest case was an empty ACL. The retrieval search took a list of identifiers the user had access to and filtered the corpus by it. If the list came in empty, the code read it as "no filter to apply" instead of "this user has access to nothing". It returned the entire corpus.
That failed open: the default for an absence of permissions was to allow everything. No happy-path test ever touches it, because on the happy path the list always has something in it.
Since then I review every authorization filter with the same question: what does this do if the collection is empty? Not "what does it do if the user has no permission", which is usually well covered, but what it does with the empty case.
The categories repeat more than you'd think
The findings almost always group into the same families.
- Permissions not enforced where they should be. An export endpoint that let data out without requiring the structure-management authority. A cascading domain delete that checked the permission once at the start instead of checking hard-delete authority over each affected item. The cascade is the classic spot: you authorize the operation and then the inner loop does things nobody authorized.
- Limits that don't exist. The admin listings passed whatever
limitarrived straight into the query, with no ceiling. A large value from a script materialized the whole table in the worker's memory. The fix fits on one line,Math.min(Math.max(limit, 1), 1000), but it wasn't there because in the UIlimitwas always ten. - State that's checked once and never again.
authenticateApiKeyvalidated the hash, the expiry and the key'sisActiveflag. But the key's row outlives the owner's account state: if the billing record gets cleaned up when someone is closed down, nobody lowers that flag and the key keeps authenticating. Now the function revalidates that the owner still has a billing record, in one place, so every key-authenticated endpoint inherits it instead of each one having to remember. - Third-party content that gets rendered. This is the one that surprised me most.
CSS is an injection too
The markdown sanitizer allowed free style and class attributes on div, span and svg. The chain goes raw to sanitize to harden and no stage looks inside the CSS: the sanitizing library treats style as an opaque string and the hardener only rewrites URLs in a and img. The style reached the final render untouched.
I thought about writing a CSS parser with a property allowlist. I didn't: I checked that no legitimate view needed style and removed both attributes from the allowed list. A homegrown CSS filter is new surface to maintain to cover a case that didn't exist.
The important thing about the finding isn't the fix, it's the class of vector. You don't need JavaScript to attack another user: with CSS you can cover a button, move text on top of other text, or change what a message appears to say. And any view that renders someone else's content, not just your own, is a cross-user vector.
Secrets in logs and links that live too long
Two findings that aren't logic bugs but lifecycle bugs.
The first: when API key auth failed because of a corrupt or malformed key, the catch put the raw key into the log context, and the logger serialized that context with JSON.stringify without redacting anything. A valid key sent to the wrong endpoint ended up in clear text in the log. The redaction I added attacks from two sides, by field name (/rawkey|token|authorization|password/i) and by value shape (the dk_live_ / dk_test_ prefix), and leaves the first eight characters visible followed by ***REDACTED***, the same format the UI uses to re-hide the key after showing it once.
The second: the signed S3 URLs for the audio player were generated with expiresIn of seven days. Seven days is an eternity for a link that only has to survive an <audio> loading. Anyone with the link — forwarded, in history, cached by a corporate proxy — kept access to the original audio for that whole window, and deleting the transcript revoked nothing. I dropped the expiry to 600 seconds, with client-side renewal at 480, and added a DeleteObjectCommand against the object when the transcript is deleted, because until then the cascade only deleted the row.
The UX cost was zero except for one flow: forwarding the audio link by email to a colleague. That flow was never a supported feature nor had any access control, and I think it's right that it stopped working.
Now I schedule it instead of remembering
The problem with audits is that they depend on you feeling like it one day. Mine happened because I was documenting design decisions and things grated while re-reading.
Each finding becomes a short file in docs/security/ with the vector and why the fix is that one and not another, and out of that comes the list I review the rest with. When I repeated the exercise against the boilerplate I start projects from, thirty-nine items came out across security, SEO and architecture, and most were versions of the same handful of questions: what happens with the empty list, who enforces the permission in the inner loop, what's the ceiling on this limit, how long does this link live.
Reading your code to confirm it works and reading it to see how to break it are two different readings. The second doesn't happen on its own while you do the first.
