Engineering
The OAuth device flow is a state machine, not a form
Implementing RFC 8628 taught me more about race conditions than any CRUD app ever did. Here’s where the concurrent states bite.
Horrorometer needed TV app support, which meant device authorisation — the RFC 8628 flow you’ve used on every streaming stick: the device shows a code, you enter it on a phone, the device polls until it’s authorised.
I estimated it at a weekend. It took substantially longer, because the device flow isn’t a form with two steps — it’s a distributed state machine where nobody holds all the state at once. Every bug I hit came from treating it like a CRUD endpoint.
The actors
Three participants, each with partial knowledge:
- The device — generates a code, displays it, and polls a token endpoint on an interval.
- The user — opens a verification URL, enters the code, and approves.
- The server — the only party that can see the whole board, and even then only through whatever storage guarantees you build.
The spec defines a handful of states — pending, user declined, expired, authorised — and a strict set of legal transitions. The work is making illegal transitions impossible.
Where the race conditions live
Two devices, one code
The code must be bound to a device and a session. If code entry and a poll land at the same moment, exactly one of them should win, and the other should get a defined answer — not a 500, not a half-authorised state.
The poller vs. the approver
The device polls every 5 seconds. The user approves at a random moment. The approval write and the next poll read can interleave — and on a serverless runtime, they might land on different isolates entirely. "Check-then-act" in application code is a bug: the check and the act have to be atomic.
Revocation vs. polling
The user changes their mind and revokes while the device is mid-poll. What does the next poll return? Undefined behaviour here is how you get devices that hold tokens nobody meant them to have.
Expiry vs. everything
The code expires after 15 minutes — but expiry has to interleave safely with approval, decline, and revocation. An approval that lands one second after expiry should fail cleanly, not resurrect a dead session.
The fix: compare-and-swap everything
The pattern that made this tractable: every state transition is a compare-and-swap. The write doesn’t say "set status to authorised" — it says "set status to authorised only if the current status is pending", and reports whether it won. D1 supports this with conditional updates; SQL is good at this, because it’s exactly the kind of guarantee databases were invented for.
That single rule eliminates whole classes of bugs:
- Two racing approvals? One wins, one gets "already authorised" — and the response to the loser is defined.
- Approval after expiry? The CAS fails, because the condition checks the expiry too.
- Revocation racing a poll? Both are CAS writes on the same row — whichever wins, the poller’s next read sees a consistent state.
Testing: simulate the races on purpose
I ended up with 20 state-machine tests, and the valuable ones are the ones that deliberately interleave:
- approve during a poll, and assert both responses are legal,
- decline and approve racing each other, and assert exactly one outcome class survives,
- fire expiry at the exact moment of authorisation, both orderings,
- duplicate device code entry, and assert the second gets a defined error.
The passing test isn’t "it worked" — it’s "it failed in the way the spec allows and nothing else".
What I took away
State machines deserve respect, and OAuth flows are state machines wearing a form UI. If your transitions are check-then-act in application code, you don’t have a state machine — you have a bug farm with extra steps. Make the storage layer enforce the transition rules, and the application layer becomes boring.
Boring, again, is the goal.
Keep reading
More from the blog.
Accessible by default: how I bake it in from the first sketch
Accessibility isn’t a checklist at the end — it’s a posture. Here’s how I keep it present from wireframe through to production code.
Shipping solo: what I learned building three live products
Horrorometer, Kanaboards, and SENDadvisor all started as itches I wanted to scratch. Some patterns held across all three — and a few surprised me.
Designing for stressful systems: lessons from SENDadvisor
Families navigating SEND services are already overwhelmed. How do you design a directory that feels calm, trustworthy, and genuinely useful?