JWT vs Session Authentication: Which Should You Use?
Revocation, refresh tokens, cookie vs localStorage, CSRF and XSS: the real trade-offs, and the patterns I recommend for web apps and APIs.
For a typical web app whose frontend and API live on the same site, use server-side sessions in an httpOnly, Secure, SameSite cookie. Use JWTs when other services, mobile clients or third parties need to verify identity without calling a central session store, and then keep access tokens short-lived with rotating refresh tokens. Neither option is "more secure" by default; storage, expiry and revocation decide that.
Key takeaways
- Sessions are stateful and trivially revocable; JWTs are stateless and hard to revoke before they expire.
- "JWT vs session" and "cookie vs header" are separate decisions. A JWT can live in a cookie, too.
- Never store long-lived tokens in
localStorage; prefer httpOnly cookies. - httpOnly stops XSS from reading the token; SameSite and CSRF tokens stop CSRF. You need both.
- If you use JWTs: 5 to 15 minute access tokens, rotating refresh tokens with reuse detection, and a revocation path.
How each approach works
Session authentication
After login, the server creates a session record (in memory, Redis or a database) and sends the browser a random, opaque session ID in a cookie. On each request the server looks up that ID. The cookie carries no information on its own; all state stays on the server.
JWT authentication
After login, the server issues a signed token containing claims such as user ID, roles and expiry. Any service with the verification key can check the signature and trust the claims without a database lookup. The token is self-contained, which is both its main benefit and its main risk.
Side-by-side comparison
| Concern | Sessions | JWT |
|---|---|---|
| Revocation / logout | Delete the session; instant | Valid until expiry unless you add a denylist |
| Per-request cost | Store lookup (sub-millisecond in Redis) | Signature check, no lookup |
| Cross-service verification | Every service needs store access | Any service with the public key |
| Stale permissions | Changes apply immediately | Old roles live until the token expires |
| Payload size | Small opaque ID | Hundreds of bytes on every request |
| Mobile and third-party clients | Workable but awkward | Natural fit (bearer tokens, OAuth 2.0) |
| Implementation risk | Low; frameworks handle it | Higher; algorithm, expiry and rotation are on you |
The "JWTs scale better" argument is weaker than it sounds. A Redis session lookup is cheap, and most apps hit the database on the same request anyway. The genuine scaling benefit of JWTs is organizational: many independent services can verify identity without sharing infrastructure.
Storage: httpOnly cookies vs localStorage
This is where most real-world vulnerabilities come from. localStorage is readable by any JavaScript on
the page. One XSS bug, or one compromised npm package in your frontend bundle, and an attacker can exfiltrate the token
and use it from anywhere until it expires.
An httpOnly cookie cannot be read by JavaScript at all. XSS is still serious (the attacker can make
requests from the victim's browser while the page is open), but they cannot steal a credential and reuse it later.
That containment matters.
- httpOnly: blocks JavaScript access.
- Secure: only sent over HTTPS.
- SameSite=Lax (or
Strict): not sent on most cross-site requests, which blocks the bulk of CSRF. - Path / Domain: scope narrowly. Use the
__Host-prefix when you can to lock the cookie to one origin.
CSRF and XSS: what protects against what
Cookies are sent automatically, so cookie-based auth (session or JWT) needs CSRF protection. Bearer tokens in an
Authorization header are not sent automatically, so they are immune to CSRF but must be stored somewhere
JavaScript can reach, which brings back XSS theft risk. The usual defenses:
SameSite=Laxcookies as a baseline.- A CSRF token (synchronizer or double-submit) on state-changing requests, especially if you need
SameSite=Nonefor cross-site embeds. - Verify the
Originheader on POST, PUT, PATCH and DELETE. - A strict Content Security Policy and output encoding to reduce XSS in the first place.
- Never change state on GET requests.
Recommended pattern 1: sessions with Express
For a same-site SPA plus API, or a server-rendered app, this is the simplest secure setup.
import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const app = express();
app.set('trust proxy', 1); // behind a load balancer / TLS terminator
app.use(session({
store: new RedisStore({ client: redis, prefix: 'sess:' }),
name: '__Host-sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 1000 * 60 * 60 * 8 },
}));
app.post('/login', async (req, res, next) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
req.session.regenerate((err) => { // prevent session fixation
if (err) return next(err);
req.session.userId = user.id;
res.json({ id: user.id, name: user.name });
});
});
app.post('/logout', (req, res) => {
req.session.destroy(() => res.clearCookie('__Host-sid', { path: '/', secure: true, httpOnly: true, sameSite: 'lax' }).sendStatus(204));
});
Note regenerate on login, which issues a new session ID so an attacker cannot plant one in advance. To
"log out everywhere", store session IDs per user in Redis and delete them all.
Recommended pattern 2: short-lived JWTs with rotating refresh tokens
When you genuinely need JWTs (mobile apps, multiple services, a separate auth server), limit the blast radius:
- Access token: 5 to 15 minutes, sent as a bearer header (held in memory in the SPA) or in an httpOnly cookie.
- Refresh token: opaque random value, stored hashed in your database, sent only in an httpOnly cookie scoped to the refresh path.
- Rotate on every use. If an old refresh token is presented again, assume theft and revoke the whole token family.
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
function issueAccessToken(user) {
return jwt.sign(
{ sub: user.id, roles: user.roles },
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '10m', issuer: 'https://auth.example.com', audience: 'api' }
);
}
app.post('/auth/refresh', async (req, res) => {
const presented = req.cookies.refresh_token;
if (!presented) return res.sendStatus(401);
const record = await db.refreshTokens.findByHash(sha256(presented));
if (!record || record.expiresAt < new Date()) return res.sendStatus(401);
if (record.usedAt) { // reuse detected: likely stolen
await db.refreshTokens.revokeFamily(record.familyId);
return res.sendStatus(401);
}
const next = crypto.randomBytes(32).toString('base64url');
// rotate() must be atomic: mark the old token used and insert the new one in one transaction,
// e.g. UPDATE ... SET used_at = now() WHERE id = $1 AND used_at IS NULL; treat 0 rows as reuse
await db.refreshTokens.rotate(record.id, {
hash: sha256(next), familyId: record.familyId, userId: record.userId,
});
res.cookie('refresh_token', next, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
maxAge: 1000 * 60 * 60 * 24 * 14,
});
res.json({ accessToken: issueAccessToken(await db.users.find(record.userId)) });
});
// Verifying: always pin the algorithm, issuer and audience
const claims = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'], issuer: 'https://auth.example.com', audience: 'api',
});
Pinning algorithms prevents the classic "alg: none" and RS/HS confusion attacks. Use asymmetric keys
(RS256 or ES256) when several services verify tokens, so only the auth server can sign. Keep claims minimal: a JWT
payload is base64-encoded, not encrypted.
Spring Security examples
Session-based (the default)
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/public/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.sessionManagement(s -> s
.sessionFixation(f -> f.changeSessionId())
.maximumSessions(3));
return http.build();
}
}
Add Spring Session with Redis to share sessions across instances. Set
server.servlet.session.cookie.same-site=lax and secure=true in configuration.
JWT resource server
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.GET, "/api/health").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // safe only because auth is a bearer header, not a cookie
return http.build();
}
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
With issuer-uri, Spring fetches the issuer's public keys (JWKS) and validates signature, expiry and
issuer for you. Do not write your own JWT filter unless you have a specific reason; the built-in resource server is
well tested. Note the comment on CSRF: disabling it is only correct when the API reads tokens from a header.
How to decide
- Server-rendered app or same-site SPA: sessions.
- Mobile apps plus web: OAuth 2.0 / OIDC with short JWT access tokens and rotating refresh tokens; consider a backend-for-frontend so the browser still only holds a session cookie.
- Many internal services: sessions at the edge, then the gateway mints short-lived internal JWTs for downstream services.
- Third-party API access: OAuth 2.0 access tokens (JWT or opaque with introspection).
Authentication is one layer of a secure API. For the surrounding design, see how to design REST APIs properly, backend architecture best practices and the edge-protection notes in Cloudflare WAF for web application security. If you want auth implemented or reviewed in a Spring application, see my Java and Spring Boot development services.
FAQ
Is JWT more secure than session authentication?
No. Neither is inherently more secure. Sessions are easier to revoke and keep sensitive state on the server, while JWTs avoid a lookup per request but are hard to revoke before expiry. Security depends mostly on how tokens are stored, transported and expired.
Should I store a JWT in localStorage?
Avoid it for long-lived tokens. Any script running on the page, including an injected XSS payload or a compromised dependency, can read localStorage. Prefer an httpOnly, Secure, SameSite cookie, or keep a short-lived access token in memory and the refresh token in an httpOnly cookie.
How do I log out a user who has a JWT?
Delete the refresh token on the server and clear the cookie, then let the short-lived access token expire. If you need immediate revocation, keep a denylist of token IDs (jti) or a per-user token version in a fast store such as Redis and check it on each request.
Do httpOnly cookies protect against CSRF?
No. httpOnly protects against JavaScript reading the cookie (XSS token theft). CSRF protection comes from SameSite=Lax or Strict cookies, CSRF tokens for state-changing requests, and checking the Origin header.
What should a single-page app with its own API use?
If the SPA and API share a site, use a server-side session in an httpOnly, SameSite cookie. It is simpler and easier to revoke than JWTs. Reach for JWTs when separate services or third parties must verify identity without calling your auth server.