Loading introduction
Loading stats
Loading about
Loading posts
Loading article
Skip to content
Md Nayeem Hossain
Explorer
home.tsx
jwt-sessions-refresh-tokens.md
jwt-sessions-refresh-tokens.md
blog.json

// Security

JWT, Sessions and Refresh Tokens: Getting Authentication Right

JWTs or sessions is the wrong question. Short-lived access tokens, rotating refresh tokens and reuse detection give you the best of both.

Md Nayeem HossainAug 11, 20254 min read
🔐

JWT, Sessions and Refresh Tokens: Getting Authentication Right#

"Should we use JWTs or sessions?" comes up in almost every new project, and the debate is usually framed wrong. They are not competing products. They are different answers to one question: where does the source of truth about a login live?

Here is how I think about it, and the setup I use for most web applications today.

Sessions: state on the server#

With classic sessions, the server stores session data and gives the browser an opaque, random session ID in a cookie. On each request, the server looks up the ID.

Strengths:

  • Instant revocation. Delete the session and the user is logged out everywhere.
  • Small cookies that carry no information.
  • Simple mental model.

Cost: a lookup on every request, usually in Redis or the database. For most applications, that is a few milliseconds and not a real problem.

JWTs: state in the token#

A JSON Web Token is signed data — the user ID, roles and an expiry — that the server can verify without a lookup. That makes it attractive for distributed systems where many services need to verify a caller.

The catch is revocation. A signed token stays valid until it expires. If you issue a JWT valid for 30 days and the user's account is compromised, you cannot invalidate it without adding a server-side denylist — which brings back the lookup you were trying to avoid.

The pattern I use: short access tokens, rotating refresh tokens#

Combining both gives the benefits of each:

  • Access token: a JWT that lives about 15 minutes. Services verify it without a database call.
  • Refresh token: an opaque random value, stored hashed in the database, valid for days or weeks. It is used only to get a new access token.

Revocation now works: delete or mark the refresh token as revoked, and within 15 minutes the user loses access everywhere.

Refresh token rotation and reuse detection#

Each time a refresh token is used, issue a new one and invalidate the old one. If an old refresh token is ever presented again, someone has a copy they should not have — so revoke the entire token family:

ts
1async refresh(presented: string) {2  const record = await this.tokens.findByHash(sha256(presented));3  if (!record) throw new UnauthorizedException();4 5  if (record.revokedAt) {6    // A rotated-out token was reused: assume theft, end every session for this user7    await this.tokens.revokeAllForUser(record.userId);8    throw new UnauthorizedException("Session expired, please sign in again");9  }10 11  await this.tokens.revoke(record.id);12  return this.issueTokens(record.userId, { familyId: record.familyId });13}

This turns a stolen refresh token from a long-lived skeleton key into something that is detected the first time either party uses it.

Where to store tokens in the browser#

This is where many implementations go wrong.

  • Refresh token: an httpOnly, Secure, SameSite cookie. JavaScript cannot read it, so an XSS bug cannot steal it.
  • Access token: in memory. It is short-lived; if the page reloads, use the refresh cookie to get a new one.
ts
1res.cookie("refresh_token", token, {2  httpOnly: true,3  secure: true,4  sameSite: "strict",5  path: "/api/v1/auth",6  maxAge: 7 * 24 * 60 * 60 * 1000,7});

Scoping the cookie path to the auth routes means the refresh token is only sent where it is needed.

Avoid storing long-lived tokens in localStorage. Any script running on your page — including a compromised third-party one — can read it.

Cookies and CSRF#

Cookies are sent automatically, which opens the door to cross-site request forgery. SameSite=Strict or Lax blocks most CSRF in modern browsers. For state-changing endpoints authenticated purely by cookie, add a CSRF token or require a custom header that cross-site requests cannot set.

JWT hygiene#

If you use JWTs, use them carefully:

  • Pin the algorithm on verification. Never accept none, and never let the token choose.
  • Keep payloads small and non-sensitive. JWTs are signed, not encrypted — anyone can read them.
  • Validate exp, iss and aud on every verification.
  • Rotate signing keys, and support multiple active keys with a key ID (kid).

Logout and "log out everywhere"#

With this design, logout revokes the current refresh token and clears the cookie. "Log out of all devices" revokes every refresh token for the user. Password changes and suspicious-activity events should trigger the same full revocation automatically.

Conclusion#

Do not pick between JWTs and sessions as if they were rival frameworks. Use short-lived JWT access tokens for fast verification, opaque rotating refresh tokens for revocable sessions, httpOnly cookies to keep refresh tokens away from JavaScript, and reuse detection to catch stolen tokens. It is a little more work than either approach alone, and much safer than both.

#Authentication#JWT#Security#Cookies
OlderWeb Performance Budgets and Core Web Vitals in PracticeNewer Background Jobs with BullMQ: Retries, Backoff and Dead Letters

Outline

  • JWT, Sessions and Refresh Tokens: Getting Authentication Right
  • Sessions: state on the server
  • JWTs: state in the token
  • The pattern I use: short access tokens, rotating refresh tokens
  • Refresh token rotation and reuse detection
  • Where to store tokens in the browser
  • Cookies and CSRF
  • JWT hygiene
  • Logout and "log out everywhere"
  • Conclusion