OAuth 2.0, including the parts that break.
Most OAuth walkthroughs stop at “you got a token.” That’s the easy half. The half that costs you a weekend is the token expiring mid-transaction, the user revoking consent in their Google settings, and the authorization code that gets replayed. Connect your own account below and trigger each one.
Runs a real authorization-code flow with PKCE against your Google account.
drive.metadata.readonly) — filenames, nothing inside them. Your tokens are held in a signed, HttpOnly cookie for one hour and are never written to a database. Disconnecting revokes the grant at Google, not just here.- scope
- —
- token
- —
- refresh
- —
These hit the deployed callback handler for real and render whatever it returns.
Authorization code, with PKCE
Six steps. The two that matter are the ones most implementations skip: generating a verifier before you leave, and proving you hold it when you come back.
The attack it exists to stop
Without PKCE, the authorization code is a bearer token in transit. It arrives back at your app through a redirect — a URL, which lands in browser history, server logs, and Referer headers, and passes through whatever is between the user and you.
An attacker who captures that code can exchange it for real tokens, because the token endpoint only asks for the code and your client ID. On a mobile app or a single-page app, where the client secret can’t be kept secret, that’s the whole attack.
PKCE closes it by making the code useless on its own. Before redirecting, the app generates a random verifier, hashes it, and sends only the hash — the challenge. Google stores the challenge with the pending authorization. When the code comes back, the app must present the original verifier; Google hashes it and compares. A stolen code without the verifier exchanges for nothing.
The verifier never leaves your side of the connection, so an attacker who sees the redirect sees only a code they cannot spend. It costs about ten lines, and it is specified in RFC 7636.
Five ways this breaks, and what the code does about each
An integration that only handles the happy path works in the demo and pages you at 3am. Every row below is handled explicitly rather than collapsing into a generic 500.
| What happens | Symptom | How it’s handled |
|---|---|---|
| User cancels consent | ?error=access_denied on the callback |
Detected before anything else is read. Redirects back with the reason, PKCE cookie cleared. Not an error state — a choice the user made. |
| State mismatch | Returned state ≠ the one issued |
Compared in constant time, then rejected. A === on a secret leaks timing information; this is the same discipline as comparing a signature. |
| Code replayed or expired | Google returns invalid_grant |
Surfaced by name rather than as a 500, because invalid_grant means something specific — the code was already spent or is stale, and the fix is to restart the flow, not to retry. |
| Access token expires mid-session | 401 from the resource API | Avoided rather than caught. The session tracks absolute expiry and refreshes when under five minutes remain, so the expiry never lands in the middle of a user’s request. |
| User revokes consent in Google settings | invalid_grant on refresh |
Session cleared and the UI asks for reconnection. Retrying a revoked grant never succeeds, so retry logic here is a bug, not resilience. |
The actual source
Public repo — read the handling rather than taking the table above on trust.
| File | What’s in it |
|---|---|
_shared.ts |
PKCE generation, HMAC-signed cookies, constant-time compare. No dependencies — Workers’ WebCrypto only. |
start.ts |
Builds the authorization URL, puts the verifier and state in a signed 10-minute cookie. Nothing stored server-side. |
callback.ts |
Code exchange, plus the four distinct failure branches from the table above. |
session.ts |
Metadata-only inspector, forced refresh with rotation, and a disconnect that revokes upstream — clearing a cookie alone leaves the grant alive in the user’s account. |
config.ts |
Reports which variables are missing, by name only. A config endpoint that leaks what it checks for is worse than none. |