Published on

JWT vs Session Tokens — Choosing the Right Authentication Strategy in 2026

Authors

Introduction

The JWT vs sessions debate has shifted from theoretical to practical. JWTs promise stateless scalability but introduce invalidation challenges. Sessions offer instant revocation but require distributed stores at scale.

The answer in 2026: it's rarely one or the other. Modern applications use hybrid approaches—sessions for user-facing applications with short-lived JWTs for service-to-service communication, and refresh token rotation to limit compromise windows.

Let's implement both patterns securely and understand their tradeoffs.

JWT Fundamentals and Stateless Tradeoffs

JWTs encode claims directly in a signed token. The server never stores anything; verification requires only the signing key.

// lib/jwt.ts
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

export interface JWTPayload {
  sub: string; // subject (user ID)
  email: string;
  role: string;
  iat: number; // issued at
  exp: number; // expiration
  iss: string; // issuer
}

export function signJWT(
  payload: Omit<JWTPayload, 'iat' | 'exp'>,
  secret: string,
  expiresIn = '15m'
): string {
  return jwt.sign(payload, secret, {
    expiresIn,
    algorithm: 'HS256',
    issuer: 'auth.example.com',
  });
}

export function verifyJWT(token: string, secret: string): JWTPayload {
  try {
    return jwt.verify(token, secret, {
      algorithms: ['HS256'],
      issuer: 'auth.example.com',
    }) as JWTPayload;
  } catch (err) {
    throw new Error(`JWT verification failed: ${err.message}`);
  }
}

The critical limitation: you cannot invalidate a JWT before expiration. A compromised token remains valid until it expires.

Mitigations:

  1. Short expiration times (15 minutes) + refresh tokens
  2. Token blacklist (defeats stateless premise but is a reasonable hybrid)
  3. JWT revocation via Redis (distributed cache keyed by token jti)
// JWT revocation cache (hybrid approach)
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function revokeJWT(jti: string, expiresIn: number) {
  // Store revoked JTI in Redis with TTL matching token expiration
  await redis.setex(`revoked_jwt:${jti}`, expiresIn, 'true');
}

export async function isJWTRevoked(jti: string): Promise<boolean> {
  const revoked = await redis.get(`revoked_jwt:${jti}`);
  return revoked !== null;
}

export async function verifyJWTWithRevocation(token: string, secret: string) {
  const payload = verifyJWT(token, secret);

  if (await isJWTRevoked(payload.jti)) {
    throw new Error('Token has been revoked');
  }

  return payload;
}

Sessions store state server-side; the client receives only a sessionId. This enables instant revocation.

// lib/sessions.ts
import crypto from 'crypto';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export interface Session {
  userId: string;
  email: string;
  role: string;
  createdAt: number;
  lastActivityAt: number;
}

export async function createSession(
  userId: string,
  email: string,
  role: string
): Promise<string> {
  const sessionId = crypto.randomBytes(32).toString('hex');

  const session: Session = {
    userId,
    email,
    role,
    createdAt: Date.now(),
    lastActivityAt: Date.now(),
  };

  // Store with 7-day TTL
  await redis.setex(
    `session:${sessionId}`,
    7 * 24 * 60 * 60,
    JSON.stringify(session)
  );

  return sessionId;
}

export async function getSession(sessionId: string): Promise<Session | null> {
  const data = await redis.get(`session:${sessionId}`);
  if (!data) return null;

  const session = JSON.parse(data) as Session;

  // Extend TTL on activity
  await redis.expire(`session:${sessionId}`, 7 * 24 * 60 * 60);

  return session;
}

export async function revokeSession(sessionId: string): Promise<void> {
  await redis.del(`session:${sessionId}`);
}

Secure session cookie setup in Next.js:

// lib/auth.ts
import { cookies } from 'next/headers';

const COOKIE_NAME = 'sessionId';
const COOKIE_OPTIONS = {
  httpOnly: true, // JavaScript cannot access; prevents XSS
  secure: process.env.NODE_ENV === 'production', // HTTPS only
  sameSite: 'strict' as const, // Prevents CSRF; blocks cross-site cookie send
  path: '/',
  maxAge: 7 * 24 * 60 * 60, // 7 days
};

export async function setSessionCookie(sessionId: string) {
  const cookieStore = await cookies();
  cookieStore.set(COOKIE_NAME, sessionId, COOKIE_OPTIONS);
}

export async function getSessionCookie(): Promise<string | undefined> {
  const cookieStore = await cookies();
  return cookieStore.get(COOKIE_NAME)?.value;
}

export async function clearSessionCookie() {
  const cookieStore = await cookies();
  cookieStore.delete(COOKIE_NAME);
}

// Middleware to attach session to request
export async function withSession<T extends Record<string, any>>(
  handler: (req: any, res: any, session: Session | null) => Promise<T>
) {
  return async (req: any, res: any) => {
    const sessionId = await getSessionCookie();
    const session = sessionId ? await getSession(sessionId) : null;
    return handler(req, res, session);
  };
}

Token Refresh Rotation

Refresh tokens enable long-lived authentication without storing long-lived access tokens. Implement refresh token rotation to detect token theft.

// lib/refreshTokens.ts
import crypto from 'crypto';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export interface RefreshTokenChain {
  token: string;
  familyId: string; // All tokens from same login share familyId
  issuedAt: number;
  rotationCount: number;
}

export async function issueRefreshTokenPair(
  userId: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const familyId = crypto.randomUUID();
  const refreshToken = crypto.randomBytes(32).toString('hex');

  const chain: RefreshTokenChain = {
    token: refreshToken,
    familyId,
    issuedAt: Date.now(),
    rotationCount: 0,
  };

  // Store refresh token with 30-day TTL
  await redis.setex(
    `refresh_token:${refreshToken}`,
    30 * 24 * 60 * 60,
    JSON.stringify(chain)
  );

  const accessToken = signJWT(
    {
      sub: userId,
      email: 'user@example.com',
      role: 'user',
    },
    process.env.JWT_SECRET!,
    '15m'
  );

  return { accessToken, refreshToken };
}

export async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{
  accessToken: string;
  refreshToken: string;
  rotationCount: number;
} | null> {
  const chainData = await redis.get(`refresh_token:${oldRefreshToken}`);
  if (!chainData) return null; // Token doesn't exist or expired

  const chain = JSON.parse(chainData) as RefreshTokenChain;

  // Detect token reuse (possible theft)
  if (chain.rotationCount > 5) {
    // Invalidate entire family
    await redis.del(`refresh_token_family:${chain.familyId}`);
    throw new Error('Suspicious refresh token rotation detected');
  }

  const newRefreshToken = crypto.randomBytes(32).toString('hex');
  const newChain: RefreshTokenChain = {
    ...chain,
    token: newRefreshToken,
    rotationCount: chain.rotationCount + 1,
    issuedAt: Date.now(),
  };

  // Store new token, delete old one
  await redis.setex(
    `refresh_token:${newRefreshToken}`,
    30 * 24 * 60 * 60,
    JSON.stringify(newChain)
  );
  await redis.del(`refresh_token:${oldRefreshToken}`);

  const accessToken = signJWT(
    {
      sub: 'userId',
      email: 'user@example.com',
      role: 'user',
    },
    process.env.JWT_SECRET!,
    '15m'
  );

  return { accessToken, refreshToken: newRefreshToken, rotationCount: chain.rotationCount + 1 };
}

JWT for Service-to-Service Authentication

JWTs shine in service-to-service communication where stateless verification is essential.

// lib/serviceAuth.ts
import jwt from 'jsonwebtoken';

export interface ServiceJWT {
  sub: string; // service name
  iat: number;
  exp: number;
  scope: string[]; // permissions
}

// Service A signs a request to Service B
export function signServiceRequest(
  serviceName: string,
  scope: string[],
  secret: string
): string {
  return jwt.sign(
    {
      sub: serviceName,
      scope,
    },
    secret,
    {
      expiresIn: '5m',
      algorithm: 'HS256',
    }
  );
}

// Service B verifies the request without calling a database
export function verifyServiceRequest(token: string, secret: string): ServiceJWT {
  return jwt.verify(token, secret, {
    algorithms: ['HS256'],
  }) as ServiceJWT;
}

// Middleware for service-to-service auth
export async function verifyServiceAuth(req: any) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    throw new Error('Missing service token');
  }

  const token = authHeader.substring(7);

  try {
    return verifyServiceRequest(token, process.env.SERVICE_SECRET!);
  } catch (err) {
    throw new Error('Invalid service token');
  }
}

Session Store Scaling

As users grow, session stores become bottlenecks. Scale horizontally with Redis or managed solutions.

// Scaled session store with Redis Cluster
import Redis from 'ioredis';

const redis = new Redis.Cluster([
  { host: 'redis-1.example.com', port: 6379 },
  { host: 'redis-2.example.com', port: 6379 },
  { host: 'redis-3.example.com', port: 6379 },
]);

export async function createScaledSession(
  userId: string,
  metadata: Record<string, any>
): Promise<string> {
  const sessionId = crypto.randomBytes(32).toString('hex');

  const session = {
    userId,
    metadata,
    createdAt: Date.now(),
  };

  // Redis handles replication and failover automatically
  await redis.setex(
    `session:${sessionId}`,
    7 * 24 * 60 * 60,
    JSON.stringify(session)
  );

  return sessionId;
}

// Pub/sub for session invalidation (logout on all devices)
export async function invalidateAllUserSessions(userId: string) {
  const keys = await redis.keys(`session:*`);

  for (const key of keys) {
    const session = JSON.parse(await redis.get(key));
    if (session.userId === userId) {
      await redis.del(key);
    }
  }

  // Publish event for other instances
  await redis.publish('user:logout', JSON.stringify({ userId, timestamp: Date.now() }));
}

redis.on('message', (channel, message) => {
  if (channel === 'user:logout') {
    const { userId } = JSON.parse(message);
    // Clear in-memory caches if using them
  }
});

Hybrid Approach: JWT + Sessions

Modern production systems combine both:

// Hybrid auth strategy
export async function hybridLogin(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user || !verifyPassword(password, user.passwordHash)) {
    throw new Error('Invalid credentials');
  }

  // Create short-lived JWT for API calls
  const accessToken = signJWT(
    {
      sub: user.id,
      email: user.email,
      role: user.role,
    },
    process.env.JWT_SECRET!,
    '15m'
  );

  // Create session for revocation capability
  const sessionId = await createSession(user.id, user.email, user.role);

  return {
    accessToken,
    refreshToken: crypto.randomBytes(32).toString('hex'),
    sessionId, // Store in httpOnly cookie
  };
}

// Verify request: check JWT + session validity
export async function verifyRequest(accessToken: string, sessionId: string) {
  const jwt = verifyJWT(accessToken, process.env.JWT_SECRET!);
  const session = await getSession(sessionId);

  if (!session) {
    throw new Error('Session invalidated');
  }

  if (session.userId !== jwt.sub) {
    throw new Error('Token/session mismatch');
  }

  return jwt;
}

Conclusion

Choose based on your needs:

  • Sessions: When you need instant revocation (e.g., after password change, logout on all devices)
  • JWTs: For service-to-service auth, mobile apps, and stateless APIs
  • Hybrid: Modern standard—short-lived JWTs + refresh token rotation + session fallback

Always use secure, HttpOnly, SameSite cookies for session IDs. Always implement refresh token rotation. Never store long-lived secrets in JWTs. Start simple; scale as needed.