Start a project

What the original advice got wrong

A decade ago, a common pattern for API authentication looked roughly like this: a login endpoint checks a username and password, generates a random token, stores it in a column next to the user row with an expiration timestamp, and hands it back. Every subsequent request presents that token, the server checks it against the database, and if it hasn’t expired, the request proceeds.

This isn’t wrong so much as incomplete, and the gaps matter. A single long-lived token that’s valid for an hour (or worse, that never expires) is a single credential that, if leaked — through a logged request, an XSS bug, a compromised dependency — grants full access for its entire lifetime, with no way to tell that it’s been stolen until someone notices unexpected activity. There’s also no separation between “this token proves who you are” and “this token lets you get a new token,” which is the distinction that makes short-lived tokens practical.

Access tokens and refresh tokens are different things

Modern token authentication (this is what OAuth 2.0’s token flows and things like JWT-based session systems converge on) splits the single token into two, with different lifetimes and different jobs:

  • Access token — short-lived, typically 5–15 minutes. Sent with every API request. If it leaks, the exposure window is measured in minutes.
  • Refresh token — longer-lived, used only to obtain a new access token when the current one expires. Sent rarely, ideally only to a single dedicated endpoint, which limits its exposure surface even though its lifetime is longer.
POST /auth/refresh
{ "refreshToken": "rt_8f14e45f..." }

→ { "accessToken": "at_...", "refreshToken": "rt_new...", "expiresIn": 900 }

The refresh call issuing a new refresh token and invalidating the old one — refresh token rotation — is the detail that makes this meaningfully more secure than a single long-lived token, not just a more complicated version of the same thing.

Rotation is what makes theft detectable

Without rotation, a stolen refresh token is valid for its entire lifetime and there’s no signal that anything’s wrong. With rotation, each refresh token is single-use: using it issues a new one and immediately invalidates the one just used. If a stolen refresh token gets used by an attacker and then the legitimate client tries to use its own (now-invalidated) copy, the server sees a refresh token reused after it was already rotated — that’s not an ambiguous signal, it’s a strong indicator of token theft, and the correct response is to revoke the entire token family (every token that descended from that refresh chain), not just the one request.

async function refresh(oldRefreshToken: string) {
  const record = await db.getRefreshToken(oldRefreshToken);
  if (!record || record.revoked) {
    // reused or unknown token: treat as a compromise, revoke the whole family
    if (record) await db.revokeTokenFamily(record.familyId);
    throw new TokenReuseDetectedError();
  }
  await db.revokeToken(oldRefreshToken);
  return await db.issueTokenPair(record.userId, record.familyId);
}

Use a vetted library instead of writing this

Every piece above — token generation, rotation, family revocation, signature verification if you’re using JWTs, clock-skew handling — is easy to get subtly wrong, and the failure modes are silent until someone exploits them. This is exactly the kind of code where “we rolled our own and it’s worked fine so far” is not evidence of correctness, just absence of an incident yet. Auth0, Clerk, AWS Cognito, and self-hosted options like Ory or Keycloak have already had this logic reviewed, attacked, and patched across a much larger population of production systems than any one team’s homegrown implementation will see. Reaching for one of these is very rarely the wrong call for anything handling real user data.

Cookies vs. headers: pick based on what’s making the request

Authorization: Bearer <token> headers and HttpOnly cookies solve different problems, and the choice isn’t purely stylistic:

  • A header-based bearer token is the natural fit for a mobile app or a server-to-server integration, where there’s no browser and no CSRF surface, but the token has to be stored somewhere in the client and is reachable by any JavaScript that runs in that context.
  • An HttpOnly, Secure, SameSite=Strict cookie is the better default for a browser-based web app, because JavaScript can’t read it — a successful XSS injection can’t simply exfiltrate it — at the cost of needing CSRF protection, since the browser will attach the cookie to requests automatically regardless of origin unless SameSite is set correctly.

A web app that stores a bearer token in localStorage “for simplicity” gives up the one protection cookies offer for free and gains none of cookies’ drawbacks in return — it’s close to the worst of both options, and it was a common mistake in exactly the era this article originally came from.

Revocation has to exist before you need it

Whatever the mechanism, there has to be a way to invalidate a token before its natural expiry — a user changes their password, reports a stolen device, or an admin needs to force a logout. A pure stateless JWT with no server-side record is hard to revoke early; a token store the server can query (even if it’s just a cache-backed set of revoked token IDs) is what makes “log this session out right now” actually possible. Design for that requirement from the start — retrofitting revocation onto a system that assumed tokens are always valid until expiry is a much bigger job than building it in from day one.

What actually changed since 2015

The mechanics moved from “one long-lived token, checked against a database column” to “two tokens with different lifetimes, rotation that turns token theft into a detectable event, and delegation to a library that has already survived being attacked in production.” None of this requires building it from scratch, and for almost every team, it shouldn’t be.


Originally published in 2015 and updated for 2026.

security · authentication · backend

30 minutes with a senior engineer.

Tell us what you're building. You'll leave with an honest opinion, even if it's "you don't need us."

Reference calls with past clients are available under NDA during evaluation.