JWT Authentication Guide | Access Tokens, Refresh Tokens, Security & Node.js

JWT Authentication Guide | Access Tokens, Refresh Tokens, Security & Node.js

이 글의 핵심

JWT authentication is easy to get wrong. This guide covers the complete production pattern: short-lived access tokens, rotating refresh tokens, HttpOnly cookie storage, Redis-based revocation, and the security pitfalls that cause breaches.

JWT Structure

A JWT is three Base64URL-encoded JSON objects joined by dots: Header.Payload.Signature. The header describes the algorithm. The payload carries claims (user ID, role, expiry). The signature is a cryptographic hash of the first two parts using a secret key — it’s what prevents anyone from forging or modifying a token.

The key property that makes JWTs useful for APIs: the server doesn’t need to look up a database to validate a token. It just verifies the signature. This makes JWT-based auth stateless and horizontally scalable. The tradeoff is that tokens can’t be invalidated before they expire — which is why short expiry times and refresh token rotation matter.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEzMTY4MDAwLCJleHAiOjE3MTMxNjg5MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header.Payload.Signature
// Decode (without verifying — don't trust unverified!)
JSON.parse(atob('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))
// Header: { alg: "HS256", typ: "JWT" }

JSON.parse(atob('eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEzMTY4MDAwLCJleHAiOjE3MTMxNjg5MDB9'))
// Payload: {
//   sub: "user123",    ← subject (user ID)
//   role: "admin",     ← custom claims
//   iat: 1713168000,   ← issued at
//   exp: 1713168900,   ← expires at (15 minutes later)
// }

The signature prevents tampering — changing any part of the payload invalidates the signature. But anyone can read the payload — don’t put secrets in it.


Setup

npm install jsonwebtoken
npm install -D @types/jsonwebtoken

Access Token + Refresh Token Pattern

Using a single long-lived JWT is the most common JWT security mistake. If that token leaks — through an XSS attack, a log file, or a browser extension — the attacker has access until it expires.

The solution is two tokens with different lifetimes. The access token is short-lived (15 minutes) and used on every API request. The refresh token is long-lived (7–30 days) and stored more carefully — it’s only sent to a dedicated /auth/refresh endpoint. When the access token expires, the client silently exchanges the refresh token for a new pair.

This limits the damage window: a stolen access token is useless in 15 minutes. A stolen refresh token can be detected and revoked (via Redis), and token rotation means a used refresh token is immediately invalidated.

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

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;

export interface TokenPayload {
  sub: string;    // user ID
  role: string;
  jti: string;    // JWT ID — unique per token
}

// Short-lived access token (15 minutes)
export function createAccessToken(userId: string, role: string): string {
  return jwt.sign(
    {
      sub: userId,
      role,
      jti: crypto.randomUUID(),
    },
    ACCESS_SECRET,
    {
      expiresIn: '15m',
      algorithm: 'HS256',
    }
  );
}

// Long-lived refresh token (7 days)
export function createRefreshToken(userId: string): string {
  return jwt.sign(
    {
      sub: userId,
      jti: crypto.randomUUID(),
    },
    REFRESH_SECRET,
    {
      expiresIn: '7d',
      algorithm: 'HS256',
    }
  );
}

export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}

export function verifyRefreshToken(token: string): TokenPayload {
  return jwt.verify(token, REFRESH_SECRET) as TokenPayload;
}

Login — Issue Tokens

// routes/auth.ts
import express from 'express';
import bcrypt from 'bcrypt';
import { createAccessToken, createRefreshToken } from '../lib/tokens';
import { db } from '../lib/db';
import { redis } from '../lib/redis';

const router = express.Router();

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.user.findUnique({ where: { email } });
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = createAccessToken(user.id, user.role);
  const refreshToken = createRefreshToken(user.id);

  // Store refresh token in Redis (for revocation on logout)
  const decoded = jwt.decode(refreshToken) as any;
  await redis.setEx(
    `refresh:${decoded.jti}`,
    7 * 24 * 60 * 60,              // 7 days TTL
    user.id
  );

  // Send access token in HttpOnly cookie
  res.cookie('access_token', accessToken, {
    httpOnly: true,                  // Not accessible via JavaScript
    secure: process.env.NODE_ENV === 'production',  // HTTPS only in production
    sameSite: 'strict',             // CSRF protection
    maxAge: 15 * 60 * 1000,        // 15 minutes
  });

  // Send refresh token in separate HttpOnly cookie
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/auth/refresh',          // Only sent to the refresh endpoint
    maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days
  });

  res.json({ user: { id: user.id, name: user.name, role: user.role } });
});

Refresh Token Rotation

Token rotation means each refresh token can only be used once. When a client exchanges a refresh token for new tokens, the old refresh token is immediately deleted from Redis. This has an important security property: if an attacker steals a refresh token and tries to use it after the legitimate client already has, Kafka detects that a token was used twice and can invalidate the session.

Without rotation, a stolen refresh token is valid for its full lifetime (days or weeks) with no way to detect the theft.

router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refresh_token;

  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  let payload: TokenPayload;
  try {
    payload = verifyRefreshToken(refreshToken);
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // Check if refresh token was revoked
  const userId = await redis.get(`refresh:${payload.jti}`);
  if (!userId) {
    return res.status(401).json({ error: 'Refresh token revoked' });
  }

  // Revoke the old refresh token (rotation — each refresh token used once)
  await redis.del(`refresh:${payload.jti}`);

  // Get fresh user data
  const user = await db.user.findUnique({ where: { id: payload.sub } });
  if (!user) {
    return res.status(401).json({ error: 'User not found' });
  }

  // Issue new token pair
  const newAccessToken = createAccessToken(user.id, user.role);
  const newRefreshToken = createRefreshToken(user.id);

  // Store new refresh token
  const newDecoded = jwt.decode(newRefreshToken) as any;
  await redis.setEx(`refresh:${newDecoded.jti}`, 7 * 24 * 60 * 60, user.id);

  // Set new cookies
  res.cookie('access_token', newAccessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000,
  });

  res.cookie('refresh_token', newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/auth/refresh',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });

  res.json({ user: { id: user.id, name: user.name, role: user.role } });
});

Logout — Revoke Tokens

router.post('/logout', authenticate, async (req, res) => {
  const refreshToken = req.cookies.refresh_token;

  if (refreshToken) {
    try {
      const payload = verifyRefreshToken(refreshToken);
      await redis.del(`refresh:${payload.jti}`);  // Revoke refresh token
    } catch {
      // Token already invalid — that's fine
    }
  }

  // Clear cookies
  res.clearCookie('access_token');
  res.clearCookie('refresh_token', { path: '/auth/refresh' });

  res.json({ message: 'Logged out successfully' });
});

Auth Middleware

// middleware/authenticate.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../lib/tokens';

export interface AuthRequest extends Request {
  user?: { id: string; role: string };
}

export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
  const token = req.cookies.access_token;

  if (!token) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  try {
    const payload = verifyAccessToken(token);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Role-based authorization
export function authorize(...roles: string[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Usage
router.get('/admin/users', authenticate, authorize('admin'), listUsers);
router.get('/profile', authenticate, getProfile);

Client-Side Token Refresh

// api/client.ts — automatically refresh on 401
const API_URL = process.env.NEXT_PUBLIC_API_URL;

async function apiCall(path: string, options: RequestInit = {}): Promise<Response> {
  let response = await fetch(`${API_URL}${path}`, {
    ...options,
    credentials: 'include',   // Send cookies with cross-origin requests
  });

  // If access token expired, refresh and retry once
  if (response.status === 401) {
    const body = await response.json();

    if (body.code === 'TOKEN_EXPIRED') {
      const refreshed = await fetch(`${API_URL}/auth/refresh`, {
        method: 'POST',
        credentials: 'include',
      });

      if (refreshed.ok) {
        // Retry original request with new token (in cookie)
        response = await fetch(`${API_URL}${path}`, {
          ...options,
          credentials: 'include',
        });
      } else {
        // Refresh failed — redirect to login
        window.location.href = '/login';
      }
    }
  }

  return response;
}

Security Checklist

Token storage:
  ✅ HttpOnly cookies (not localStorage)
  ✅ Secure flag (HTTPS only)
  ✅ SameSite=Strict or Lax (CSRF protection)

Token configuration:
  ✅ Short expiry for access tokens (15 min)
  ✅ Refresh token rotation (invalidate on use)
  ✅ Different secrets for access and refresh tokens
  ✅ Include jti claim for revocation

Secrets:
  ✅ Strong secrets (256+ bits of entropy)
  ✅ Stored in environment variables, not code
  ✅ Different secrets per environment

Common mistakes:
  ❌ Storing tokens in localStorage (XSS vulnerable)
  ❌ Long-lived access tokens (hours/days)
  ❌ No refresh token rotation
  ❌ Putting sensitive data in payload (it's readable)
  ❌ Using 'none' algorithm
  ❌ Not validating exp and iat claims
  ❌ Sharing JWT secrets across services

Related posts: