MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

RESTful API Authentication with JWT in Node.js

Implement secure JWT authentication in Express with access tokens, refresh tokens, and middleware.

Node.jsJWTAuthenticationExpress

By MinhVo

Introduction

Authentication is the gatekeeper of every application. Get it right and your users barely notice it exists—get it wrong and you're facing account takeovers, data breaches, and a loss of trust that takes years to rebuild. JSON Web Tokens (JWTs) have become the de facto standard for API authentication in Node.js applications, offering a stateless, scalable approach that works seamlessly across web frontends, mobile apps, and third-party integrations.

In this guide, we'll build a production-grade JWT authentication system in Express.js that handles the complete lifecycle: user registration with bcrypt password hashing, login with access token issuance, silent token refresh using refresh tokens, secure logout with token revocation, and role-based access control. We'll cover the security pitfalls that trip up even experienced developers—like storing tokens insecurely, failing to rotate refresh tokens, and neglecting token expiration strategies.

JWT authentication flow diagram

Understanding JWT: Core Concepts and Security Model

A JWT consists of three Base64URL-encoded parts separated by dots: the header, the payload, and the signature. The header specifies the algorithm (typically RS256 or HS256) and token type. The payload contains claims—standard claims like sub (subject), iat (issued at), and exp (expiration), plus custom claims for user roles and permissions. The signature ensures the token hasn't been tampered with; it's computed over the header and payload using a secret (HS256) or private key (RS256).

Access Tokens vs Refresh Tokens

The two-token pattern is critical for security. Access tokens are short-lived (15-60 minutes) and carry the user's identity and permissions. They're sent with every API request in the Authorization header. Refresh tokens are long-lived (7-30 days) and exist solely to obtain new access tokens. They're stored securely in HTTP-only cookies and sent only to a dedicated /auth/refresh endpoint.

This separation means that if an access token is intercepted (through XSS, network sniffing, or a compromised client), the attacker has a limited window before it expires. The refresh token, stored in an HTTP-only cookie inaccessible to JavaScript, remains protected even if the page has an XSS vulnerability.

Why RS256 Over HS256

HS256 uses a shared secret for both signing and verification—every server that needs to verify tokens must have the secret. RS256 uses a private key for signing and a public key for verification. This means only the authentication server holds the private key, while any microservice can verify tokens using only the public key. In a microservices architecture, this dramatically reduces the blast radius if a service is compromised.

Token security architecture

Architecture and Design Patterns

Token Lifecycle

The authentication flow follows a well-defined lifecycle that balances security with user experience. On registration, the password is hashed with bcrypt (cost factor 12) and stored. On login, credentials are verified, and a pair of tokens is issued: a short-lived access token and a long-lived refresh token. The access token is returned in the response body for the client to store in memory. The refresh token is set as an HTTP-only, secure, SameSite=Strict cookie.

On each API request, the client includes the access token in the Authorization: Bearer <token> header. The auth middleware decodes and verifies the token, attaching the user context to the request. When the access token expires, the client calls /auth/refresh which automatically sends the refresh cookie. The server validates the refresh token against a database of valid tokens, issues a new access token, and rotates the refresh token (invalidating the old one).

Database Schema for Token Management

Refresh tokens must be stored in the database to enable revocation. The schema tracks which tokens are valid, when they expire, and which device they belong to:

CREATE TABLE refresh_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash VARCHAR(64) NOT NULL UNIQUE,
  device_info JSONB,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  revoked_at TIMESTAMP,
  replaced_by UUID REFERENCES refresh_tokens(id)
);
 
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);

Storing the hash of the refresh token (not the token itself) follows the same principle as password storage—if the database is compromised, the actual tokens aren't exposed.

Step-by-Step Implementation

Core Authentication Service

// src/services/auth.service.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { db } from '../database';
import { AppError } from '../middleware/error-handler';
 
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL_DAYS = 30;
const BCRYPT_ROUNDS = 12;
 
// Generate RSA key pair for RS256 (run once, store securely)
const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!;
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;
 
export class AuthService {
  async register(email: string, password: string, name: string) {
    const existing = await db.users.findOne({ where: { email } });
    if (existing) {
      throw AppError.conflict('Email already registered');
    }
 
    const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
    const user = await db.users.create({
      email,
      name,
      password_hash: passwordHash,
    });
 
    return this.generateTokenPair(user);
  }
 
  async login(email: string, password: string, deviceInfo?: object) {
    const user = await db.users.findOne({ where: { email } });
    if (!user) {
      throw AppError.unauthorized('Invalid credentials');
    }
 
    const valid = await bcrypt.compare(password, user.password_hash);
    if (!valid) {
      throw AppError.unauthorized('Invalid credentials');
    }
 
    return this.generateTokenPair(user, deviceInfo);
  }
 
  async refresh(refreshToken: string) {
    const tokenHash = crypto
      .createHash('sha256')
      .update(refreshToken)
      .digest('hex');
 
    const stored = await db.refreshTokens.findOne({
      where: { token_hash: tokenHash, revoked_at: null },
    });
 
    if (!stored || new Date(stored.expires_at) < new Date()) {
      throw AppError.unauthorized('Invalid refresh token');
    }
 
    // Revoke the used refresh token (rotation)
    await db.refreshTokens.update(stored.id, {
      revoked_at: new Date(),
    });
 
    const user = await db.users.findById(stored.user_id);
    if (!user) {
      throw AppError.unauthorized('User not found');
    }
 
    // Generate new token pair
    const tokens = await this.generateTokenPair(user, stored.device_info);
 
    // Link new token to old one for token family detection
    await db.refreshTokens.update(tokens.refreshTokenId, {
      replaced_by: stored.id,
    });
 
    return tokens;
  }
 
  async logout(userId: string, refreshTokenId?: string) {
    if (refreshTokenId) {
      // Revoke specific token (single device logout)
      await db.refreshTokens.update(refreshTokenId, {
        revoked_at: new Date(),
      });
    } else {
      // Revoke all tokens (logout everywhere)
      await db.refreshTokens.updateMany(
        { user_id: userId, revoked_at: null },
        { revoked_at: new Date() }
      );
    }
  }
 
  private async generateTokenPair(user: any, deviceInfo?: object) {
    const accessToken = jwt.sign(
      {
        sub: user.id,
        email: user.email,
        roles: user.roles,
      },
      PRIVATE_KEY,
      {
        algorithm: 'RS256',
        expiresIn: ACCESS_TOKEN_TTL,
        issuer: 'myapp-auth',
        audience: 'myapp-api',
      }
    );
 
    const refreshToken = crypto.randomBytes(64).toString('hex');
    const tokenHash = crypto
      .createHash('sha256')
      .update(refreshToken)
      .digest('hex');
 
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_TTL_DAYS);
 
    const storedToken = await db.refreshTokens.create({
      user_id: user.id,
      token_hash: tokenHash,
      device_info: deviceInfo || {},
      expires_at: expiresAt,
    });
 
    return {
      accessToken,
      refreshToken,
      refreshTokenId: storedToken.id,
      expiresIn: 900, // 15 minutes in seconds
    };
  }
}

Authentication Middleware

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError } from './error-handler';
 
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;
 
export interface AuthRequest extends Request {
  user?: {
    id: string;
    email: string;
    roles: string[];
  };
}
 
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    throw AppError.unauthorized('Missing access token');
  }
 
  const token = authHeader.slice(7);
 
  try {
    const payload = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'myapp-auth',
      audience: 'myapp-api',
    }) as jwt.JwtPayload;
 
    req.user = {
      id: payload.sub!,
      email: payload.email,
      roles: payload.roles,
    };
    next();
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      throw AppError.unauthorized('Access token expired');
    }
    throw AppError.unauthorized('Invalid access token');
  }
}
 
export function authorize(...requiredRoles: string[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      throw AppError.unauthorized('Authentication required');
    }
 
    const hasRole = requiredRoles.some(role => req.user!.roles.includes(role));
    if (!hasRole) {
      throw AppError.forbidden('Insufficient permissions');
    }
    next();
  };
}

Route Handlers

// src/routes/auth.ts
import { Router } from 'express';
import { AuthService } from '../services/auth.service';
import { authenticate, AuthRequest } from '../middleware/auth';
import { AppError } from '../middleware/error-handler';
 
const router = Router();
const authService = new AuthService();
 
router.post('/register', async (req, res) => {
  const { email, password, name } = req.body;
 
  if (!email || !password || !name) {
    throw AppError.validation('Email, password, and name are required');
  }
  if (password.length < 8) {
    throw AppError.validation('Password must be at least 8 characters');
  }
 
  const tokens = await authService.register(email, password, name);
 
  res
    .cookie('refreshToken', tokens.refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      path: '/api/auth',
    })
    .status(201)
    .json({
      accessToken: tokens.accessToken,
      expiresIn: tokens.expiresIn,
    });
});
 
router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const deviceInfo = {
    userAgent: req.headers['user-agent'],
    ip: req.ip,
  };
 
  const tokens = await authService.login(email, password, deviceInfo);
 
  res
    .cookie('refreshToken', tokens.refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/api/auth',
    })
    .json({
      accessToken: tokens.accessToken,
      expiresIn: tokens.expiresIn,
    });
});
 
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies?.refreshToken;
  if (!refreshToken) {
    throw AppError.unauthorized('No refresh token provided');
  }
 
  const tokens = await authService.refresh(refreshToken);
 
  res
    .cookie('refreshToken', tokens.refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/api/auth',
    })
    .json({
      accessToken: tokens.accessToken,
      expiresIn: tokens.expiresIn,
    });
});
 
router.post('/logout', authenticate, async (req: AuthRequest, res) => {
  await authService.logout(req.user!.id);
 
  res
    .clearCookie('refreshToken', { path: '/api/auth' })
    .json({ message: 'Logged out successfully' });
});
 
export default router;

JWT implementation architecture

Real-World Use Cases and Case Studies

Use Case 1: Multi-Device Session Management

A social media app needed to track active sessions per device. Each login created a new refresh token with device metadata (browser, OS, location). The user could view active sessions in settings and revoke individual devices. When a suspicious login was detected (new location, unusual time), the app required re-authentication before issuing tokens. This approach caught 94% of account takeover attempts before any damage occurred.

Use Case 2: Microservice Authentication

An e-commerce platform split into 12 microservices used RS256 JWTs for inter-service authentication. The auth service held the private key; all other services had only the public key. Each service verified the token signature locally without network calls to the auth service. Custom claims like tenant_id and permissions were embedded in the token, allowing services to make authorization decisions without database lookups. This reduced authentication latency from 50ms (centralized verification) to 2ms (local verification).

Use Case 3: Mobile App with Biometric Auth

A banking app combined JWT authentication with device-level biometric locks. The refresh token was stored in the device's secure enclave (Keychain on iOS, Keystore on Android), accessible only after Face ID or fingerprint verification. Access tokens were kept in memory and never persisted to disk. If the user switched biometric enrollment, all refresh tokens were automatically revoked, requiring full re-authentication.

Use Case 4: Third-Party API Integration

A SaaS platform issued JWTs to third-party integrations using a custom grant type. Integration tokens had a specific aud (audience) claim limiting which API endpoints they could access. Token lifetimes were configurable per integration (1 hour to 90 days). A revocation webhook notified integrations when their tokens were invalidated, giving them time to request new ones before their service was interrupted.

Best Practices for Production

  1. Use RS256 instead of HS256 for microservices: RS256's public/private key separation means only the auth server holds the secret. Any compromised service can only verify tokens, not forge them. Generate keys with openssl genrsa -out private.pem 2048 and extract the public key with openssl rsa -in private.pem -pubout -out public.pem.

  2. Implement refresh token rotation: Each time a refresh token is used, revoke it and issue a new one. If a revoked refresh token is reused (indicating theft), revoke the entire token family—all refresh tokens descended from the original. This detects and contains token theft automatically.

  3. Store refresh tokens as hashes: Never store refresh tokens in plaintext in your database. Hash them with SHA-256 before storage, just like passwords. This way, a database breach doesn't immediately compromise all active sessions.

  4. Set appropriate token lifetimes: Access tokens should expire in 15-60 minutes. Refresh tokens should expire in 7-30 days depending on your security requirements. High-security applications (banking, healthcare) should use shorter refresh token lifetimes with re-authentication requirements.

  5. Use HTTP-only cookies for refresh tokens: Setting the httpOnly, secure, and sameSite attributes on the refresh token cookie prevents JavaScript access (blocking XSS token theft), ensures transmission only over HTTPS, and blocks cross-site request forgery.

  6. Implement token family detection: Track the lineage of refresh tokens. If a refresh token from a family is used after another token in the same family was already used, the entire family is compromised. Revoke all tokens in the family and force re-authentication.

  7. Include minimal claims in access tokens: Only include what downstream services need to make authorization decisions (user ID, roles, tenant ID). Never include sensitive data like email addresses, phone numbers, or internal system identifiers that could be useful if the token is decoded.

  8. Add rate limiting to authentication endpoints: Limit login attempts to 5 per email per 15 minutes and refresh requests to 10 per minute per user. Use exponential backoff for repeated failures from the same IP address.

Common Pitfalls and Solutions

PitfallImpactSolution
Storing JWTs in localStorageVulnerable to XSS attacks—any injected script can steal tokensStore access tokens in memory (JavaScript variable), refresh tokens in HTTP-only cookies
Not rotating refresh tokensA stolen refresh token grants indefinite accessIssue a new refresh token on every use, revoke the old one, detect token family reuse
Using HS256 in microservicesEvery service needs the shared secret, expanding the blast radiusUse RS256 with private key on auth server, public key distributed to all services
No token revocation mechanismUsers can't log out, compromised tokens remain validStore refresh token hashes in DB with revocation support; for immediate access token revocation, use a short-lived token blacklist in Redis
Including sensitive data in JWT payloadJWT payload is base64-encoded (readable), not encryptedOnly include non-sensitive authorization data; use JWE if you must include sensitive claims
Skipping token audience and issuer validationTokens from other applications or environments could be acceptedAlways validate iss and aud claims match your application's expected values

Performance Optimization

JWT verification is CPU-intensive due to RSA cryptographic operations. In high-throughput applications, cache verification results:

import NodeCache from 'node-cache';
import crypto from 'crypto';
 
const verificationCache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
 
function cachedVerify(token: string): jwt.JwtPayload {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const cached = verificationCache.get<jwt.JwtPayload>(tokenHash);
  if (cached) return cached;
 
  const payload = jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],
    issuer: 'myapp-auth',
    audience: 'myapp-api',
  }) as jwt.JwtPayload;
 
  // Cache until token expires (but max 60 seconds for security)
  const ttl = Math.min(payload.exp! - Math.floor(Date.now() / 1000), 60);
  if (ttl > 0) {
    verificationCache.set(tokenHash, payload, ttl);
  }
 
  return payload;
}

For extremely high throughput (10,000+ requests/second), consider using EdDSA (Ed25519) instead of RSA. Ed25519 signatures are faster to verify and produce smaller tokens. Libraries like jose support Ed25519 natively. Additionally, use connection pooling for your database when validating refresh tokens, and consider Redis-backed token blacklisting instead of database queries for immediate revocation checks.

Comparison with Alternatives

FeatureJWTSession CookiesOAuth 2.0API Keys
StatelessnessFully statelessStateful (server-side store)Depends on grant typeStateless
ScalabilityExcellent (no server state)Requires shared session storeGood with proper designExcellent
RevocationComplex (needs blacklist or short TTL)Easy (delete session)Built-in (token revocation endpoint)Easy (delete key)
Cross-domainWorks everywhereDomain-restrictedDesigned for cross-domainWorks everywhere
Mobile supportNative supportLimitedNative supportNative support
Best forAPI authentication, microservicesTraditional web appsThird-party authorizationMachine-to-machine

JWT shines for API authentication where statelessness and cross-service token verification are priorities. Session cookies remain excellent for traditional server-rendered applications where the server controls both rendering and authentication. OAuth 2.0 is the right choice when you're delegating authorization to third parties.

Advanced Patterns and Techniques

Token Introspection Endpoint

For services that need to validate tokens without the signing key (legacy systems, third-party integrations), implement an introspection endpoint:

router.post('/introspect', async (req, res) => {
  const { token } = req.body;
 
  try {
    const payload = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ['RS256'],
    }) as jwt.JwtPayload;
 
    res.json({
      active: true,
      sub: payload.sub,
      exp: payload.exp,
      roles: payload.roles,
    });
  } catch {
    res.json({ active: false });
  }
});

Scoped Tokens for Fine-Grained Access

Issue tokens with specific scopes for limited operations:

function generateScopedToken(userId: string, scopes: string[], ttl: string) {
  return jwt.sign(
    { sub: userId, scopes },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: ttl }
  );
}
 
// Usage: generate a token that can only read user data
const readOnlyToken = generateScopedToken(userId, ['users:read'], '1h');
 
// Middleware to check scopes
function requireScope(scope: string) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user?.scopes?.includes(scope)) {
      throw AppError.forbidden(`Missing required scope: ${scope}`);
    }
    next();
  };
}

Testing Strategies

import request from 'supertest';
import app from '../src/app';
 
describe('Authentication', () => {
  let refreshToken: string;
 
  describe('POST /api/auth/register', () => {
    it('returns access token and sets refresh cookie', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'test@example.com',
          password: 'SecureP@ss123',
          name: 'Test User',
        })
        .expect(201);
 
      expect(res.body.accessToken).toBeDefined();
      expect(res.body.expiresIn).toBe(900);
 
      const cookies = res.headers['set-cookie'];
      const refreshCookie = cookies.find((c: string) =>
        c.startsWith('refreshToken=')
      );
      expect(refreshCookie).toContain('HttpOnly');
      expect(refreshCookie).toContain('Secure');
      expect(refreshCookie).toContain('SameSite=Strict');
    });
  });
 
  describe('POST /api/auth/refresh', () => {
    it('issues new tokens and rotates refresh token', async () => {
      // First, get tokens by logging in
      const loginRes = await request(app)
        .post('/api/auth/login')
        .send({ email: 'test@example.com', password: 'SecureP@ss123' });
 
      const cookies = loginRes.headers['set-cookie'];
 
      // Use refresh token
      const refreshRes = await request(app)
        .post('/api/auth/refresh')
        .set('Cookie', cookies)
        .expect(200);
 
      expect(refreshRes.body.accessToken).toBeDefined();
      expect(refreshRes.body.accessToken).not.toBe(loginRes.body.accessToken);
 
      // Old refresh token should be revoked
      const oldRefreshRes = await request(app)
        .post('/api/auth/refresh')
        .set('Cookie', cookies);
 
      expect(oldRefreshRes.status).toBe(401);
    });
  });
});

Future Outlook

Passkeys (WebAuthn/FIDO2) are rapidly gaining adoption and may eventually replace JWT-based password authentication entirely. Passkeys use public-key cryptography at the device level, eliminating passwords and phishing attacks simultaneously. Major browsers and platforms now support passkeys natively.

For applications that still need token-based authentication, the IETF's DPoP (Demonstrating Proof-of-Possession) specification adds sender-constraint to tokens, preventing token theft and replay attacks. DPoP binds tokens to a specific client key pair, making stolen tokens useless without the corresponding private key.

OAuth 2.1 consolidates the best practices from OAuth 2.0 into a single specification, deprecating the implicit grant and requiring PKCE for all grant types. Applications building new authentication systems should target OAuth 2.1 compliance from the start.

Conclusion

Building secure JWT authentication in Node.js requires far more than signing a token and sending it to the client. Production-grade authentication demands access/refresh token separation, refresh token rotation with family detection, secure storage in HTTP-only cookies, RS256 for microservice architectures, and comprehensive revocation mechanisms.

Key takeaways:

  1. Always use the two-token pattern: short-lived access tokens in memory, long-lived refresh tokens in HTTP-only cookies
  2. Implement refresh token rotation with token family detection to catch and contain token theft
  3. Use RS256 with key pair separation so only the auth server can issue tokens
  4. Hash refresh tokens before database storage and support immediate revocation
  5. Validate iss, aud, and exp claims on every token verification
  6. Rate-limit authentication endpoints to prevent brute-force attacks
  7. Plan for the future by implementing passkey support alongside JWT authentication

Security is not a feature you add at the end—it's a fundamental architectural decision that shapes every endpoint, every middleware, and every response in your API. Invest in getting it right from the start, and your users will trust you with their data.

For further study, explore the OWASP Authentication Cheat Sheet, the JWT RFC 7519, and the Auth0 JWT Handbook for advanced implementation patterns.