ehsan.blog
~/blog/api-client-part-5-auth-oauth2-refresh — zsh
cat api-client-part-5-auth-oauth2-refresh.md

Auth, all four ways — and the OAuth2 refresh race that breaks most apps

·5 min read

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

StrategyHow it attaches authTypical use case
bearerAuthorization: Bearer <token> headerJWT-based APIs (this is what MERN Notes uses)
cookieRelies on browser-managed session cookiesSame-origin apps, session-based backends
apiKeyA header or query param you name yourselfServer-to-server, internal tools, third-party APIs
oauth2Bearer token + automatic refresh on expiryAnything with a refresh-token lifecycle
ts
// 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

ts
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:

plaintext
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 token

The 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.

ts
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: bearer for JWT APIs, cookie when the browser handles sessions, apiKey for server-to-server, oauth2 for anything with refresh-token rotation.
  • The real value of the oauth2 strategy isn’t attaching a header — it’s coalescing concurrent 401s into a single refresh call instead of racing.
  • onRefreshFailure fires once per genuine session-end, not once per failed request — use it to clear state and redirect, not to retry.
  • skipAuth exists 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.