← Writing

What I found reviewing an app my coding agent built

The bugs that passed tests and looked fine in a diff: authorization checks that weren't where I assumed.

· 3 min read· Guard

Claude Code writes most of the code in our household finance app. I decide what gets built, review what comes back, and keep the backlog. After a few months of shipping that way, I set aside time to read the app the way an attacker would, rather than the way a reviewer reads a diff.

Every finding below is fixed. None of them was caught by tests, and none of them looked obviously wrong in the pull request that introduced it.

1. Server actions that trusted the layout

The settings, profile and notification pages sat under a layout that checked who you were. The server actions those pages called did not check again. In a diff that looks fine: the page is protected. But a server action is its own endpoint and can be called directly, so "the page is protected" says nothing about the action.

Lesson: authorization belongs where the work happens, not where the button is drawn.

2. The Claude connector ignored roles

The app has viewers who can see but not change things. The MCP connector that lets Claude query our finances checked whether you were a member of the household, not what role you held. So a viewer could connect, and if write access was switched on for the household, could write through Claude.

The fix was to apply the same role check the web app uses. The reason it was missed is more interesting: the connector was built as "a new way in", and nobody, me included, asked whether it should honour the rules of the existing ways in.

Lesson: every new interface to the same data is a new place to forget the rules. Agents are an interface.

3. A link that didn't check which household it was linking

Linking a person to an account owner checked that you were allowed to make links, but not that the owner belonged to your household. The database's row-level security would have stopped most reads across that line, which is exactly why I put authorization in the database in the first place. But the write itself should never have been accepted.

Lesson: defence in depth worked, and that is not a reason to leave the outer layer open.

4. A deadlock hiding inside the safety mechanism

Every query runs inside a wrapper that sets the household context for row-level security. One read, computing a giving balance, called another function that opened its own wrapper. Each held a connection while waiting for another, and the pool ran dry.

Lesson: the safety mechanism is code too, and it has its own failure modes.

5. The connector rejected claude.ai's tokens

Not a security bug, but the mirror image of one. During this period the endpoint started rejecting claude.ai's own tokens because of an audience claim it didn't expect, and was fixed within the hour. A system that fails closed is annoying; after the list above, it was also reassuring.

What I changed in how I work

  • I review by question, not by diff. "Who can call this, and what stops them?" for every endpoint, instead of "does this change look right?"
  • New interfaces get a parity check. If the web app enforces something, the connector, the iPhone app and the Slack actions must enforce it too, and there should be a test that says so.
  • The backlog is fenced. Some issues are marked as needing my design sign-off and are explicitly off-limits to the daily automated routine. Authorization changes are one of those categories.

An agent writes code that does what you asked. Most of these bugs were things I hadn't asked for, because I hadn't thought of them either. Reviewing an agent's work means looking for what's missing, which is harder than looking for what's wrong.