Skip to content
·7 min read

JWT vs Sessions vs OAuth in 2026, Pick the Right One

Auth advice on the internet is mostly cargo cult. "Use JWT" was the 2018 answer. The 2026 answer is "it depends, and probably not localStorage JWT." Here's the real framework.

AuthenticationJWTSessionsBackendSecurity

Auth advice on the internet is mostly cargo cult. "Use JWT" was the 2018 answer. "JWT is bad, use sessions" was the 2022 answer. The 2026 answer is: it depends, and probably not localStorage JWT.

Here's the actual decision framework, from someone who has shipped all three patterns in production.

The three patterns

1. Sessions (server-side state)

Client logs in
  → Server creates a session in its database
  → Server sends a cookie with the session ID
  → Cookie is httpOnly (JavaScript can't read it)
  → On every request, server reads the cookie, looks up the session, gets the user

2. JWT (stateless tokens)

Client logs in
  → Server signs a JWT containing the user ID and metadata
  → Server sends the JWT (in a cookie or as a bearer token)
  → On every request, server verifies the JWT signature
  → No database lookup needed

3. OAuth (login with someone else)

Client clicks "Login with Google"
  → Redirect to Google
  → User authorizes
  → Google redirects back with an authorization code
  → Server exchanges the code for user info
  → Server creates a session or JWT for the user

OAuth is not an alternative to sessions or JWT, it's an alternative to passwords. You still need to decide what to do after the OAuth flow.

The decision tree

Do you have multiple backend services that need to verify auth?
├── Yes → JWT in httpOnly cookie
└── No → Do you render HTML on the server?
    ├── Yes → Sessions
    └── No (SPA or mobile API) → JWT in httpOnly cookie (web) or secure storage (mobile)

Sessions are the default. Use them unless you have a specific reason not to.

JWT is for stateless cross-service auth. Use it when you have a separate API server and can't share session state.

OAuth is for login-with, not for session management. Use it to avoid password storage.

Why sessions win for most web apps

Sessions are simpler and safer. Here's why:

  1. Revocation is trivial. Delete the session row in the database. The user is logged out. With JWT, you need a blacklist (which defeats the "stateless" benefit) or you wait for the token to expire.

  2. No client-side storage. The cookie contains only an opaque ID. Even if XSS steals the cookie, the attacker has a random string, not user data.

  3. No cryptographic bugs. JWT libraries are easy to misuse. Sessions are just a database lookup.

  4. Cookies work everywhere. Browsers handle them automatically. No Authorization: Bearer headers to manage.

The cost: every request hits the session store. For most apps, this is a 1-2ms Redis lookup. Negligible.

When JWT actually wins

JWT is the right tool when:

  • You have multiple backend services. Service A issues a JWT, services B and C verify it without talking to A or to a shared session store.
  • You're building a mobile API. Mobile apps don't have cookies (or they're awkward). JWT in secure storage is the standard.
  • You need to pass auth claims to a frontend without a database lookup. E.g., a microfrontend reads the user's role from the JWT.

JWT is the wrong tool when:

  • You're storing it in localStorage (use a session instead, see below)
  • You're using it for "API statelessness" but still looking it up in the database on every request (just use sessions)
  • You're putting sensitive data (PII, secrets) in the JWT payload (it's signed, not encrypted, anyone can read it)

The localStorage trap

This is the most common auth mistake on the internet:

// DO NOT DO THIS
localStorage.setItem('token', jwt)

localStorage is readable by any JavaScript on your page. Third-party scripts, npm packages, even a compromised CDN can read it. A single XSS vulnerability gives an attacker every user's token.

The only safe place for a token in the browser is an httpOnly cookie:

// Setting the cookie (in your login route)
res.cookies.set('session', token, {
  httpOnly: true,      // JavaScript cannot read this
  secure: true,        // HTTPS only
  sameSite: 'lax',     // CSRF protection
  maxAge: 60 * 60 * 24 * 7,  // 7 days
  path: '/',
})

The browser automatically sends the cookie on every request. The client never touches the token directly.

Refresh token rotation

Long-lived tokens are dangerous. Short-lived tokens are annoying (users log in constantly). The compromise: refresh token rotation.

Access token: 15 minutes
Refresh token: 7 days

Every 15 minutes:
  → Client uses refresh token to get a new access token
  → Server issues a new refresh token
  → Old refresh token is invalidated

If a refresh token is used twice (replay attack):
  → Server detects it
  → Entire token family is revoked
  → User has to log in again

Implementation in Node.js with jose:

import { SignJWT, jwtVerify } from 'jose'
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
 
// Create tokens on login
async function createTokens(userId: string) {
  const access = await new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('15m')
    .sign(secret)
 
  const refresh = await new SignJWT({ userId, type: 'refresh' })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('7d')
    .sign(secret)
 
  return { access, refresh }
}
 
// Verify
async function verify(token: string) {
  const { payload } = await jwtVerify(token, secret)
  return payload
}

Store refresh tokens in the database (or Redis) so you can invalidate them. A pure stateless refresh token can't be revoked, which defeats the point.

OAuth for login-with

If you're building a consumer app, offer OAuth. Users hate making new passwords. You get verified emails for free.

The flow (simplified):

// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
  const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
  url.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID!)
  url.searchParams.set('redirect_uri', 'https://app.com/auth/google/callback')
  url.searchParams.set('response_type', 'code')
  url.searchParams.set('scope', 'openid email profile')
  res.redirect(url.toString())
})
 
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
  const code = req.query.code
  // Exchange code for tokens
  const tokens = await exchangeCodeForTokens(code)
  // Get user info
  const userInfo = await fetchGoogleUserInfo(tokens.access_token)
  // Find or create user in your database
  const user = await findOrCreateUser(userInfo)
  // Create your own session or JWT
  const sessionToken = await createSession(user.id)
  res.cookie('session', sessionToken, { httpOnly: true, secure: true })
  res.redirect('/dashboard')
})

Use a library like NextAuth, Lucia, or Passport instead of writing this from scratch. They handle the edge cases (PKCE, state validation, token refresh).

The setup I ship for client projects

For a Next.js app with server rendering:

  1. Email + password stored with bcrypt (or better, Argon2)
  2. Google + Apple OAuth alongside email/password
  3. Sessions stored in Redis (fast lookup, easy revocation)
  4. Cookies with httpOnly, Secure, SameSite=Lax
  5. Session TTL of 7 days, sliding expiration (renewed on activity)
  6. CSRF protection via SameSite cookies + token for mutations

For a mobile API:

  1. Email + password and OAuth
  2. JWT access token (15 minutes) + refresh token (30 days)
  3. Refresh token rotation with replay detection
  4. Refresh tokens stored in the database so they can be revoked
  5. Push notifications when a new device logs in

Both setups are boring and well-tested. Don't invent anything new in auth.

The 2026 answer to "JWT or sessions?" is "sessions, unless you have a specific reason for JWT, and never in localStorage." The 2026 answer to "should I offer OAuth?" is "yes."

Want auth done right?

I set up auth for client apps, email + password, OAuth, sessions, refresh rotation, the production-grade setup that doesn't break under XSS or CSRF attacks. Let's talk.