This is the fifth time I’ve gone hunting for a failure mode in the coaching agent I build and run. The first four found something. A prompt that leaked the tool list. A memory I could not poison but learned a lot trying. A paid endpoint with no rate limit. A destructive tool that fired on a two-word message. This one’s different. I went looking for the worst bug in the stack and the boundary held.
The boundary is this. Every user’s data lives in a separate Durable Object. One per person. All their workouts, plans, conversations, memory. If that wall has a crack, one user can read or wreck another user’s data. That’s the bug that ends an app. So I tried to make the crack.
The Attack I Wanted
The classic version of this is boring and devastating. It has a name, BOLA, broken object level authorization. You ask for object 124 and get it. You ask for 125 and get someone else’s. The server checked that you were logged in. It never checked that the thing you asked for was yours. Most real breaches are some flavor of this. Ask for an id that’s not yours and see if the server hands it over.
So I set up two accounts. A and B. A made a conversation, a scheduled session, and a workout. I grabbed their ids. Then as B I walked every door in the app holding A’s ids.
Read A’s conversation. Post a message into A’s conversation. Read A’s planned session. Edit it. Start it. Read A’s workout. Delete A’s workout. Then I dropped the login entirely and tried to read A’s private workout as nobody at all.
Nine attempts. Every one denied. Nothing of A’s came back to B. Nothing came back to the anonymous caller. I checked the scariest one twice, the delete. B asked the server to delete A’s workout and the server said 200, OK. My stomach dropped. Then I logged back in as A and the workout was still there. The delete had hit nothing.
Why It Held
The reason is the part worth keeping. The isolation isn’t a check. It’s an address.
When B logs in, the server reads B’s identity from the signed token. Not from the URL, not from the body, from the token. Then it builds the database handle from that identity.
const id = env.WL_DURABLE_OBJECT.idFromName(user.email);
const stub = env.WL_DURABLE_OBJECT.get(id);That handle points at B’s Durable Object and only B’s. When B then asks to delete A’s workout id, the delete runs against B’s database, where that id doesn’t exist. It deletes nothing and says so. B never gets a handle to A’s data, because the handle is built from B’s token, and B can’t forge A’s token without the signing secret.
This is isolation by address, not isolation by check. The difference matters. A check is a question you ask on every read. Is this row yours. Is this one yours. Forget the question once, on one route, and that route leaks. The whole class of BOLA bugs is a forgotten check. Address-based isolation has no question to forget. You’re handed a room and the room only has your things in it. You couldn’t reach into another room if you tried, because you were never given its door.
It’s the same idea I keep landing on in this series. Only code binds. A check is a line a tired engineer can skip. An address is structural. The wall is the architecture, not a rule written on top of it.
The ids help too. They’re random UUIDs, not 124 and 125. So even the guessing game that powers most BOLA attacks doesn’t start. But the ids are the smaller reason. Even if B knew A’s exact workout id, and B did, it knew it because I handed it over, the address still pointed at the wrong room.
The One Thing That Was Wrong
The boundary held, but one door reported the denial badly. When B asked for A’s workout, the server returned a 500. A server error. Not a 404.
No data leaked. The 500 was the server tripping over its own feet on the way to denying the request. The workout wasn’t in B’s database, so the lookup came back empty, and the code tried to format an empty result into a response that requires a real one. It threw, and the catch turned the throw into a 500.
That’s still a bug. A denial that looks like a crash is its own problem. A 500 tells an attacker the code path is fragile, which is an invitation to push on it. It tells an honest user something is broken when nothing is. And it’s the one response on the whole probe I couldn’t tell apart from a real failure at a glance. The fix is small. When the workout isn’t in your database, say so. Return a 404. Not found is the honest answer, and it’s the same answer whether the workout never existed or simply was never yours.
if (!meta) {
return errorResponse('Workout not found', 404);
}The delete had the mirror version of the same sin. It denied correctly, deleted nothing, and then said 200 OK. A success code for a no-op. That one is already on my list as a separate issue, so I left it there rather than drag it into an isolation fix. But it’s the same lesson twice. The boundary did its job, and then the response lied about what happened. A denial you can’t distinguish from success, or from a crash, undoes some of the value of denying at all.
What I Take From It
I’ve spent five rounds looking for ways my agent breaks. This is the first where the answer was, it doesn’t, here’s why. That’s worth writing down as much as a break is. The thing that made it hold wasn’t vigilance. It was that the isolation lives in the address, so there’s no per-request check to forget. Pick a primitive where the safe thing is the only thing you can do, and you stop relying on remembering.
The two warts are the smaller half of the story but the more useful half for anyone building this. Get your boundary right and then make sure it tells the truth when it stops someone. A 404 that means both not found and not yours. A clear no on a write that changed nothing. The boundary and the boundary’s honesty are two different jobs.
The worker has the fix and the two-account probe is the regression test. Next I want to point this same probe at the agent’s tool-result payloads, the data the model reads back mid-loop. The address protects the database. I want to know what the model trusts once the data is in its hands.