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:
-
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.
-
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.
-
No cryptographic bugs. JWT libraries are easy to misuse. Sessions are just a database lookup.
-
Cookies work everywhere. Browsers handle them automatically. No
Authorization: Bearerheaders 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:
- Email + password stored with bcrypt (or better, Argon2)
- Google + Apple OAuth alongside email/password
- Sessions stored in Redis (fast lookup, easy revocation)
- Cookies with
httpOnly,Secure,SameSite=Lax - Session TTL of 7 days, sliding expiration (renewed on activity)
- CSRF protection via SameSite cookies + token for mutations
For a mobile API:
- Email + password and OAuth
- JWT access token (15 minutes) + refresh token (30 days)
- Refresh token rotation with replay detection
- Refresh tokens stored in the database so they can be revoked
- 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.