How to Implement JWT Security

How-To Guide

How to Implement JWT Security

Build secure token-based authentication without the pitfalls

TL;DR

Use RS256 with asymmetric keys, not HS256 with a shared secret. Keep access tokens short-lived (15 min), store them in httpOnly cookies, and rotate refresh tokens on every use. Validate the algorithm, expiry, issuer, and audience on every request -- never skip a claim.

Prerequisites:

  • Understanding of authentication concepts
  • Node.js or similar backend
  • A way to generate/store secrets or key pairs

Why This Matters

JWT misconfiguration is one of the more consistent findings in our scans of vibe-coded apps. The "none" algorithm attack, weak HS256 secrets, tokens stored in localStorage, and skipped claim validation each show up regularly and each on their own is enough to let an attacker impersonate any user. Getting JWT right means handling all of them.

Step-by-Step Guide

1

Choose the right signing algorithm

// AVOID: HS256 with weak secrets
// AVOID: The "none" algorithm
// GOOD: RS256 (RSA) or ES256 (ECDSA) for production

import { generateKeyPairSync } from 'crypto';

// Generate RSA key pair (do this once, store securely)
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

// Or for ECDSA (smaller tokens)
const { publicKey: ecPublicKey, privateKey: ecPrivateKey } =
  generateKeyPairSync('ec', {
    namedCurve: 'P-256',
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
  });

// Store keys in environment variables or secrets manager
// Never commit private keys to git!
2

Create tokens with proper claims

import jwt from 'jsonwebtoken';

const ACCESS_TOKEN_EXPIRY = '15m';  // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d';   // Longer-lived

function createTokens(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,              // Subject (user ID)
      email: user.email,
      role: user.role
    },
    process.env.JWT_PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: ACCESS_TOKEN_EXPIRY,
      issuer: 'https://myapp.com',    // Who issued the token
      audience: 'https://myapp.com',   // Who can use it
      jwtid: crypto.randomUUID()       // Unique token ID
    }
  );

  const refreshToken = jwt.sign(
    {
      sub: user.id,
      type: 'refresh'
    },
    process.env.JWT_PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: REFRESH_TOKEN_EXPIRY,
      issuer: 'https://myapp.com',
      jwtid: crypto.randomUUID()
    }
  );

  // Store refresh token hash in database for revocation
  await db.refreshToken.create({
    data: {
      userId: user.id,
      tokenHash: hashToken(refreshToken),
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    }
  });

  return { accessToken, refreshToken };
}
3

Validate tokens properly

import jwt from 'jsonwebtoken';

async function validateAccessToken(token) {
  try {
    const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],        // CRITICAL: Specify allowed algorithms
      issuer: 'https://myapp.com',  // Verify issuer
      audience: 'https://myapp.com', // Verify audience
      complete: true                 // Get header info too
    });

    // Check if token is blacklisted (for revocation)
    const isBlacklisted = await db.blacklistedToken.findUnique({
      where: { jti: decoded.payload.jti }
    });

    if (isBlacklisted) {
      throw new Error('Token has been revoked');
    }

    return decoded.payload;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    if (error.name === 'JsonWebTokenError') {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

// Middleware
async function authMiddleware(req, res, next) {
  const token = req.cookies.accessToken;  // From httpOnly cookie

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

  try {
    const user = await validateAccessToken(token);
    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({ error: error.message });
  }
}
4

Store tokens securely

// WRONG: localStorage is vulnerable to XSS
localStorage.setItem('token', accessToken);

// CORRECT: Use httpOnly cookies (server sets them)
function setAuthCookies(res, accessToken, refreshToken) {
  // Access token cookie
  res.cookie('accessToken', accessToken, {
    httpOnly: true,     // Can't be accessed by JavaScript
    secure: true,       // HTTPS only
    sameSite: 'strict', // Prevent CSRF
    maxAge: 15 * 60 * 1000,  // 15 minutes
    path: '/'
  });

  // Refresh token cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/api/auth/refresh'  // Only sent to refresh endpoint
  });
}

// Clear cookies on logout
function clearAuthCookies(res) {
  res.clearCookie('accessToken');
  res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
}
5

Implement refresh token rotation

async function refreshAccessToken(req, res) {
  const refreshToken = req.cookies.refreshToken;

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

  try {
    // Verify refresh token
    const decoded = jwt.verify(refreshToken, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'https://myapp.com'
    });

    // Check if refresh token exists in database
    const storedToken = await db.refreshToken.findFirst({
      where: {
        userId: decoded.sub,
        tokenHash: hashToken(refreshToken),
        expiresAt: { gt: new Date() }
      }
    });

    if (!storedToken) {
      // Token reuse detected! Revoke all user's tokens
      await db.refreshToken.deleteMany({
        where: { userId: decoded.sub }
      });
      clearAuthCookies(res);
      return res.status(401).json({
        error: 'Invalid refresh token. Please login again.'
      });
    }

    // Delete old refresh token (rotation)
    await db.refreshToken.delete({
      where: { id: storedToken.id }
    });

    // Get user and create new tokens
    const user = await db.user.findUnique({
      where: { id: decoded.sub }
    });

    const { accessToken, refreshToken: newRefreshToken } = createTokens(user);
    setAuthCookies(res, accessToken, newRefreshToken);

    return res.json({ success: true });
  } catch (error) {
    clearAuthCookies(res);
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
}
6

Handle token revocation

// For immediate revocation (logout, password change, etc.)
async function revokeAllUserTokens(userId) {
  // Delete all refresh tokens
  await db.refreshToken.deleteMany({
    where: { userId }
  });

  // For access tokens, we need a blacklist (they're stateless)
  // Option 1: Blacklist specific tokens until they expire
  // Option 2: Track a "tokens valid after" timestamp per user
}

// Logout endpoint
async function logout(req, res) {
  const accessToken = req.cookies.accessToken;
  const refreshToken = req.cookies.refreshToken;

  if (accessToken) {
    // Blacklist access token until expiry
    const decoded = jwt.decode(accessToken);
    if (decoded?.jti && decoded?.exp) {
      await db.blacklistedToken.create({
        data: {
          jti: decoded.jti,
          expiresAt: new Date(decoded.exp * 1000)
        }
      });
    }
  }

  if (refreshToken) {
    // Delete refresh token from database
    await db.refreshToken.deleteMany({
      where: { tokenHash: hashToken(refreshToken) }
    });
  }

  clearAuthCookies(res);
  return res.json({ success: true });
}

// Clean up expired blacklist entries (run periodically)
async function cleanupBlacklist() {
  await db.blacklistedToken.deleteMany({
    where: { expiresAt: { lt: new Date() } }
  });
}

JWT Security Mistakes to Avoid:

  • Never allow the "none" algorithm. Specify algorithms: ['RS256'] explicitly.
  • Never store JWTs in localStorage. XSS can read it; httpOnly cookies can't be touched by JS.
  • Never use a weak HS256 secret. If it's guessable, the whole token system collapses.
  • Never put sensitive data in the payload. It's base64, not encrypted -- anyone can decode it.
  • Never skip claim validation (exp, iss, aud). A valid signature proves nothing else.

How to Verify It Worked

  1. Test algorithm confusion: Send a token with alg="none" -- your server must reject it
  2. Test expiry: Wait past the access token lifetime, then try the token; expect a 401
  3. Test refresh rotation: Use the same refresh token twice; the second call must fail and ideally revoke all tokens for that user
  4. Test revocation: Logout, then replay the old access token against a protected endpoint

Common Errors & Troubleshooting

Error: "invalid algorithm"

You're verifying with a different algorithm than used for signing. Check that algorithms array matches.

Error: "invalid signature"

Key mismatch. For RS256, sign with private key, verify with public key.

Tokens not being sent

Check cookie settings - sameSite, secure, and path must be correct for your setup.

Token too large

JWTs in cookies have ~4KB limit. Don't put large data in tokens - use user ID and fetch data as needed.

Why not just use sessions instead?

Sessions are often simpler and more secure for traditional web apps. JWTs make sense for: stateless microservices, mobile apps, or when you need tokens to work across domains. For most web apps, sessions are fine.

What's the deal with HS256 vs RS256?

HS256 uses a shared secret (both sides need the same key). RS256 uses public/private keys (sign with private, verify with public). RS256 is better when you have multiple services - they can verify tokens without having the signing key.

How do I handle token refresh in a SPA?

The access token cookie is sent automatically. When you get a 401, call your /refresh endpoint. If that fails, redirect to login. Use an interceptor in your HTTP client to handle this automatically.

Related guides:Session Management · Secure Login Form · OAuth Setup

How-To Guides

How to Implement JWT Security