By Super Washington BangaPost-mortem
Med-Core v1.0JWT race condition — scope checked after the middleware
Post-mortem on a race condition where concurrent requests replayed an un-scoped JWT through the middleware, and the fix that moved scope validation into the request pipeline where it belongs.
What Worked
- The regression test reproduced the race deterministically with asyncio barriers
- Moving scope checks into the dependency chain made the failure impossible to reintroduce
What Failed
- Scope validation lived in the client wrapper, not the request middleware
- The race window was invisible to the old sequential test suite
- No ticket tracked the middleware gap after the sprint review
Key Lessons
- Authorization belongs in the request pipeline, never in the caller
- Race conditions need concurrency-aware tests, not more fixtures
- Security gaps found in review need a ticket before the sprint closes
What happened
Two concurrent requests with the same un-scoped token: one handler checked the scope claim and raised 403, but a second request had already passed the client wrapper — where the scope check lived — before the middleware re-check ran. The token was never re-validated between the wrapper and the handler, so the second request sailed through with a scope it was never granted.
The sequential test suite could not see the window: every request in the old tests completed its check before the next one started.
The fix
Scope validation moved out of the client wrapper and into the dependency chain, next to authentication, so every request — regardless of caller — passes through it:
@app.post("/intake", dependencies=[Depends(require_scope("intake:write"))])
async def ingest(payload: IntakePayload) -> Receipt:
...
The regression test uses an asyncio.Barrier to line up both requests inside
the middleware, so the race fails deterministically if it ever returns:
await asyncio.gather(issue_request(token, barrier), issue_request(token, barrier))