Skip to content

The wall in my code: what a security audit taught me that no bootcamp will

I shipped it, and then I paid strangers to attack it

Eight weeks into building Payle, I did something most pre-seed founders don't do: I hired someone to find everything wrong with my code.

Not a code review from a friend. Not a "looks good to me" from a cofounder. A full external audit of the money path: the authorization engine, the ledger, every place where a euro could move without a human understanding why.

I want to tell you what it found, because the list humbled me, taught me more than any tutorial ever has, and contains lessons that no bootcamp in the world includes in its curriculum.

Finding one: the race that could double-charge

My authorization endpoint checked the budget, then wrote the decision, then recorded the idempotency key. Three steps. Linear. Obvious.

Except that HTTP doesn't care about my sense of order. Two identical requests arriving at the same millisecond, and agents retry aggressively because that is their nature, could both pass the check, both write a decision, and both authorize a payment. Double charge. Same user. Same second.

The fix sounds boring and is beautiful: the idempotency claim and the decision must live in the same database transaction. Insert the key first. If another request got there first, return its stored response. If not, this request owns the key and the decision. One transaction. One truth.

In payments, correctness isn't about what your code does. It is about what your code does when two copies of it run at the same time.

Concurrency is where junior code and production code diverge.

Finding two: the ledger that lied to itself

I was proud of my hash-chained ledger. Every entry linked to the previous one with sha256. Tamper-evident, like a blockchain, but honest about being a database.

The audit found two holes in it. First: I read the previous hash outside the transaction that appended the new entry, meaning two concurrent writes could fork the chain. Second: one lifecycle path wrote a literal string as a hash instead of computing one. My own verification tool, pointed at my own healthy system, would have cried "TAMPERED."

Both fixed. Both now covered by tests that fail if anyone ever regresses them.

A security property you haven't tested is a decoration, not a property.

"The ledger is immutable" is a sentence. "This test proves the chain verifies after 10,000 mixed-lifecycle operations" is a fact.

Finding three: the deletion that would have erased everyone

This one still keeps me up at night, a little.

My account-deletion endpoint deleted ledger entries. With no WHERE clause. Meaning: one user tapping "delete my account" would have wiped the audit trail of every customer in the system. The audit called it what it was: a global data destruction bug hiding behind a routine feature.

The fix: the ledger is never deleted, ever. Account deletion pseudonymizes the user's identity while preserving the financial chain. Roles at the database level now enforce what the application used to promise.

Destructive operations need to be scoped at the database level, not by application discipline.

Application code changes. Privileges don't, unless you make them.

Finding four: fail-safe vs fail-open

When the risk service is down, what should the payment system do? Block everything, or approve everything?

I had built neither, because I hadn't built the risk service yet, so the question was theoretical. The audit forced it to become practical: it wired the fail-safe path, made "risk service unreachable" a first-class state, and added the rule I now consider sacred: when uncertain, a human approves. Machines don't guess with money.

The full list was longer

Missing rate limiting. PII in logs. A config that boots happy when malformed and dies on first request. An ID verification flow that a 2023-era attacker would laugh at. Each finding was a conversation between me and my own assumptions.

And the reason I'm writing this isn't humility. It's arithmetic: every one of these bugs, found after launch, costs ten times more, in money, in trust, in YC interviews where a partner asks "how do you know your ledger doesn't fork?" and you don't have an answer with a test in it.

What I'd tell every builder now

If you're building anything that touches money, do this before your next feature: 1. Get someone whose job is breaking your assumptions. 2. Fix what they find with tests that fail before the fix. 3. Keep those tests in CI forever, so the lesson can't be unlearned.

The audit didn't just find bugs. It taught me the difference between code that works and code that deserves to hold someone's money. That difference is the entire fintech industry, compressed.

No bootcamp covers it. You get it by paying strangers to attack the thing you love.

Worth every cent.

Weitere Notizen

Gedanken