Auth, all four ways — and the OAuth2 refresh race that breaks most apps
I mentioned in part one that a race condition in a hand-rolled OAuth2 refresh flow was one of the three bugs that pushed me to build this properly. This post is that story in full, plus the three simpler auth strategies that cover most apps.
The four strategies, and when you’d actually use each
| Strategy | How it attaches auth | Typical use case |
|---|---|---|
bearer | Authorization: Bearer <token> header | JWT-based APIs (this is what MERN Notes uses) |
cookie | Relies on browser-managed session cookies | Same-origin apps, session-based backends |
apiKey | A header or query param you name yourself | Server-to-server, internal tools, third-party APIs |
oauth2 | Bearer token + automatic refresh on expiry | Anything with a refresh-token lifecycle |
// bearer — the common case for JWT APIs
auth: { strategy: 'bearer', getToken: () => localStorage.getItem('access_token') }
// cookie — nothing to attach, just tell the client not to try
auth: { strategy: 'cookie' }
// pair with: http: { withCredentials: true }
// apiKey — you choose where it goes
auth: {
strategy: 'apiKey',
getKey: () => process.env.API_KEY!,
placement: 'header', // or 'query'
name: 'X-API-Key',
}Problem cookie solves: some backends genuinely don’t want you touching
auth headers at all — the session cookie is the whole story, and trying to
also attach a bearer token is either redundant or actively wrong. cookie
strategy just tells the pipeline “auth is handled by the browser, don’t
attach anything yourself.”
OAuth2 — the one that actually needs explaining
auth: {
strategy: 'oauth2',
getAccessToken: () => store.accessToken,
getRefreshToken: () => store.refreshToken,
refresh: async (refreshToken) => {
const res = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
})
const { accessToken, refreshToken: newRefresh } = await res.json()
return { accessToken, refreshToken: newRefresh }
},
onRefreshFailure: () => store.logout(),
}Four pieces: read the current access token, read the current refresh token, a function that trades a refresh token for a new pair, and a callback for when refresh itself fails (expired refresh token → log the user out).
The bug that actually happened to me
Picture a dashboard page that fires four requests on mount — user profile,
notifications, recent activity, account settings. All four use the same
expired access token. All four get a 401 back at roughly the same
millisecond.
A naive OAuth2 implementation reacts to each 401 independently:
Request A (401) ──▶ call refresh() ──▶ new token A
Request B (401) ──▶ call refresh() ──▶ new token B ← different token!
Request C (401) ──▶ call refresh() ──▶ new token C ← different token!
Request D (401) ──▶ call refresh() ──▶ new token D ← different token!Four refresh calls, hitting your auth server four times for one expired token. Some refresh-token implementations invalidate the old refresh token the moment it’s used — which is correct behavior on the server’s part, and exactly the rotation pattern I wrote about for MERN Notes. But it means request B’s refresh call is using a refresh token that request A’s refresh call just invalidated. B fails. The user gets logged out, even though their session was actually fine thirty seconds ago — it just got caught in a self-inflicted race.
Coalescing: one refresh, four waiters
sequenceDiagram
participant A as Request A
participant B as Request B
participant C as Request C
participant P as Pipeline
participant S as Auth server
A->>P: 401 received
P->>S: refresh() — first caller
B->>P: 401 received
P-->>B: wait, refresh in flight
C->>P: 401 received
P-->>C: wait, refresh in flight
S-->>P: new access token
P-->>A: retry with new token
P-->>B: retry with new token
P-->>C: retry with new tokenThe pipeline tracks whether a refresh is already in progress. The first
401 triggers the real refresh() call. Every other 401 that arrives
while that call is still pending doesn’t trigger a second call — it just
waits for the same promise to resolve, then retries with whatever token
comes back. One refresh call, all four original requests retried
successfully, no race.
This is the same shape of problem as request deduplication (covered in the next post) — many callers, one underlying operation, everyone shares the result — just applied to token refresh instead of GET requests.
onRefreshFailure — the part people forget
Problem: the refresh token itself is expired or revoked. refresh()
throws. Now what? Without a defined answer, you get an infinite retry loop,
or a silent failure where the user just sees broken data with no indication
they’ve been logged out.
Solution: onRefreshFailure is called exactly once when the refresh call
itself fails, and it’s the right place to clear local session state and
redirect to login. It fires once per genuine session-end, not once per
failed request — the coalescing from the diagram above means all the
requests that were waiting on that refresh fail together, but you still only
get a single onRefreshFailure call, not four.
skipAuth — the escape hatch
Not every endpoint needs auth. Login and public health-check endpoints shouldn’t have a bearer token attached at all — and definitely shouldn’t trigger a refresh attempt if they happen to 401.
await api.auth.login(credentials, { skipAuth: true })Real talk: I forgot this option existed on an early project and instead
special-cased the login endpoint by checking if (path !== '/auth/login')
inside a hook. Works, but it’s exactly the kind of ad-hoc branching this
library exists to avoid. If you find yourself writing a conditional based on
which endpoint you’re calling, check whether a per-call option already
covers it first.
What sticks from this post
- Four strategies cover almost everything:
bearerfor JWT APIs,cookiewhen the browser handles sessions,apiKeyfor server-to-server,oauth2for anything with refresh-token rotation. - The real value of the
oauth2strategy isn’t attaching a header — it’s coalescing concurrent401s into a single refresh call instead of racing. onRefreshFailurefires once per genuine session-end, not once per failed request — use it to clear state and redirect, not to retry.skipAuthexists so you don’t have to hand-roll endpoint exceptions with hooks.
Next: caching — strategies, why cache keys are safer than you’d build by hand, and invalidation.
- developerehsan