本文へ移動
Zomer Gregorio

Zomer Gregorio

ソフトウェアエンジニア

履歴書
言語
← ブログ

Node.js: How to Implement Secure Stateless Sessions with HttpOnly Cookies and JWT

· Authentication · Node.js · Security · TypeScript · API

Learn how to build a robust, stateless session architecture in Node.js using signed JWTs stored in HttpOnly, SameSite cookies to mitigate XSS and CSRF threats.

更新情報を受け取る

新しい記事を公開したときに短いお知らせを送ります。メールまたはブラウザの購読情報は通知配信のためだけに保存され、いつでも解除できます。アカウントや追跡用プロフィールは不要です。

通知を開始する前に確認メールでの承認が必要です。

Architectural Overview of Stateless Sessions

Designing authentication systems for distributed Node.js applications often forces a choice between stateful server-side sessions and stateless token-based architectures. Stateful sessions require a shared distributed cache, such as Redis, to maintain session state across horizontal scaling boundaries. Stateless tokens, particularly JSON Web Tokens (JWTs), embed claims directly within the payload, eliminating centralized lookup overhead during request validation.

However, storing JWTs in browser local storage or session storage exposes the application to severe Cross-Site Scripting (XSS) vulnerabilities. Any malicious dependency or injected script can easily access localStorage.getItem('token') and exfiltrate credentials. To retain the scalability benefits of stateless JWTs while neutralizing XSS risks, authentication tokens must be stored in browser cookies configured with strict security flags.

Setting Cookie Security Flags

Mitigating common web vectors relies on setting three critical attributes on the session cookie: HttpOnly, Secure, and SameSite. Combining these parameters ensures the token cannot be accessed via client-side JavaScript and limits transmission vulnerabilities.

import { Response } from 'express';
 
export function setSessionCookie(res: Response, token: string): void {
  res.cookie('__Host-session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 15 * 60 * 1000, // 15 minutes
    domain: 'api.example.com'
  });
}

The HttpOnly flag prevents client-side scripts from reading or modifying the cookie via document.cookie. The Secure flag ensures the browser transmits the cookie exclusively over encrypted HTTPS connections, preventing cleartext sniffing on local networks. The SameSite=Lax attribute mitigates Cross-Site Request Forgery (CSRF) by withholding the cookie on cross-site subrequests while allowing top-level navigation, such as clicking a link.

Notice the __Host- prefix in the cookie name. This prefix enforces strict browser constraints: the cookie must be set with the Secure flag, from a secure origin, without a domain attribute (meaning it is scoped strictly to the host that set it), and with path=/. Using this prefix prevents cookie-planting attacks from rogue subdomains.

Issuing and Validating Tokens in Express

Implementing the authentication lifecycle requires two primary endpoints: an issuance handler (login) and an authentication middleware. The issuance handler validates user credentials, generates a signed JWT containing minimal non-sensitive claims, and writes the token into the secure cookie.

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
 
interface TokenPayload {
  sub: string;
  role: string;
}
 
export async function handleLogin(req: Request, res: Response): Promise<void> {
  const user = await authenticateUser(req.body.email, req.body.password);
  if (!user) {
    res.status(401).json({ error: 'Invalid credentials' });
    return;
  }
 
  const payload: TokenPayload = { sub: user.id, role: user.role };
  const secret = process.env.JWT_SECRET;
  
  if (!secret) {
    throw new Error('JWT_SECRET environment variable is not defined');
  }
 
  const token = jwt.sign(payload, secret, { expiresIn: '15m' });
 
  res.cookie('__Host-session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 900000
  });
 
  res.status(200).json({ status: 'success' });
}

Incoming requests must be validated by extracting the token from the cookie header rather than the standard Authorization: Bearer header. The Express middleware parses the incoming cookie store, verifies the cryptographic signature using the secret key, and attaches the decoded claims to the request object.

export interface AuthenticatedRequest extends Request {
  user?: TokenPayload;
}
 
export function requireAuth(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
  const token = req.cookies?.['__Host-session'];
 
  if (!token) {
    res.status(401).json({ error: 'Authentication required' });
    return;
  }
 
  try {
    const secret = process.env.JWT_SECRET;
    if (!secret) throw new Error('JWT_SECRET is missing');
    
    const decoded = jwt.verify(token, secret) as TokenPayload;
    req.user = decoded;
    next();
  } catch (err) {
    res.status(403).json({ error: 'Invalid or expired token' });
  }
}

Handling Token Revocation and Rotation

A common critique of stateless JWTs is the inability to instantly revoke a token before its natural expiration time. If a user's permissions change or their credentials are compromised, a standard JWT remains valid until its exp claim passes.

To bridge this gap without sacrificing stateless scalability, combine short-lived access tokens with a lightweight revocation strategy. When a user logs out or requires immediate termination, store the token's unique identifier (jti) or the user's ID in a high-speed data store like Redis with a TTL matching the token's remaining lifetime.

The verification middleware checks this revocation list before accepting the token:

import { redisClient } from './redis';
 
async function isTokenRevoked(jti: string): Promise<boolean> {
  const result = await redisClient.get(`revoked:${jti}`);
  return result !== null;
}

By checking Redis only when a token is evaluated, you maintain high throughput while gaining instant revocation capabilities. For optimal performance, cache negative lookups locally or rely on an asynchronous distributed cache cluster.

Managing Refresh Token Flows

Short-lived access tokens (e.g., 15 minutes) reduce the window of exposure if a token leaks. To maintain a seamless user experience, implement a refresh token rotation pattern. Issue two distinct cookies upon authentication: an access token cookie with a short lifespan and a refresh token cookie with a longer lifespan (e.g., 7 days) bound to a dedicated endpoint like /api/auth/refresh.

The refresh token should be stored securely and rotated upon every use. If a refresh token is presented a second time after its rotation, the authorization server must treat the event as a token theft attempt, invalidating all active sessions for that user ID.

Operational Considerations and Failure Modes

Deploying cookie-based authentication behind reverse proxies and load balancers requires careful handling of proxy headers. Express must be configured to trust proxy headers so that the application correctly identifies secure HTTPS connections:

app.set('trust proxy', 1);

Without this configuration, the secure: true option will cause Express to reject the cookie issuance when requests pass through an SSL-terminating load balancer that forwards traffic to Node.js over plain HTTP.

Additionally, monitor cryptographic signing algorithms closely. Never use the none algorithm or weak secrets. Enforce explicit algorithm validation during verification:

jwt.verify(token, secret, { algorithms: ['HS256'] });

Failing to restrict algorithms explicitly can leave applications vulnerable to algorithm substitution attacks, where an attacker modifies the JWT header to specify asymmetric validation algorithms using a public key interpreted as an HMAC secret.