ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeJWT Authentication Done Right in Node.js (Most Tutorials Get This Wrong)

JWT Authentication Done Right in Node.js (Most Tutorials Get This Wrong)

RS256 keys, refresh token rotation, httpOnly cookies, and the security decisions most tutorials skip entirely

#JWT authentication Node.js#refresh token rotation#RS256 JWT#JWT security 2026#httpOnly cookie JWT#token rotation Node.js#RBAC middleware Express
Z
ZyVOP

Senior Developer

May 24, 2026
8 min read
19 views
JWT Authentication Done Right in Node.js (Most Tutorials Get This Wrong)

JWT authentication is one of those things every developer has implemented at least once, and most have implemented wrong at least once. The basic pattern is simple enough to get working in an afternoon. The subtleties that make it production-safe take a post-mortem or two to learn the hard way.

This guide skips the "here is what a JWT is" introduction. You know what a JWT is. This is about the implementation decisions that determine whether your auth is solid or quietly broken.


The Setup

npm install jsonwebtoken bcryptjs cookie-parser

The full picture: users log in, get a short-lived access token (15 minutes) and a long-lived refresh token (7 days). The access token lives in memory on the client. The refresh token lives in an httpOnly cookie. When the access token expires, the client silently gets a new one using the refresh token.

This is the pattern. Any variation that skips refresh tokens or stores tokens in localStorage has a security hole in it.


Step 1 — Key Setup

Use asymmetric keys (RS256), not a shared secret (HS256). With HS256, any service that can verify a token can also forge one. With RS256, only the auth service holds the private key; every other service only needs the public key to verify.

# Generate RS256 key pair
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

Store them as environment variables:

# .env (never commit this)
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..."
JWT_REFRESH_SECRET="a-separate-long-random-string-for-refresh-tokens"

Generate the refresh secret:

openssl rand -hex 64

Step 2 — Token Generation

// src/lib/tokens.js
import jwt from 'jsonwebtoken';

const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY.replace(/\\n/g, '\n');
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY.replace(/\\n/g, '\n');
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

export function generateAccessToken(user) {
  return jwt.sign(
    {
      sub: user.id,          // Subject — the user ID
      role: user.role,       // Keep payload minimal
      type: 'access',
    },
    PRIVATE_KEY,
    {
      algorithm: 'RS256',
      expiresIn: '15m',
      issuer: 'your-app-name',
      audience: 'your-app-api',
    }
  );
}

export function generateRefreshToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      type: 'refresh',
      // Include a version number — increment it to invalidate all sessions
      tokenVersion: user.tokenVersion,
    },
    REFRESH_SECRET,
    {
      algorithm: 'HS256',   // Symmetric is fine for refresh — only your server uses it
      expiresIn: '7d',
    }
  );
}

export function verifyAccessToken(token) {
  return jwt.verify(token, PUBLIC_KEY, {
    algorithms: ['RS256'],        // NEVER allow 'none' — this is the alg:none attack
    issuer: 'your-app-name',
    audience: 'your-app-api',
  });
}

export function verifyRefreshToken(token) {
  return jwt.verify(token, REFRESH_SECRET, {
    algorithms: ['HS256'],
  });
}

The algorithms whitelist is not optional. Without it, an attacker can forge a token by setting "alg": "none" in the header — the JWT spec allows tokens with no signature, and vulnerable libraries will accept them. Always explicitly specify which algorithms you accept.


Step 3 — Login Route

// src/routes/auth.js
import bcrypt from 'bcryptjs';
import { generateAccessToken, generateRefreshToken } from '../lib/tokens.js';

const REFRESH_COOKIE_OPTIONS = {
  httpOnly: true,           // Not accessible via JavaScript — prevents XSS theft
  secure: process.env.NODE_ENV === 'production',  // HTTPS only in prod
  sameSite: 'strict',       // Prevents CSRF
  maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days in ms
  path: '/auth/refresh',    // Cookie only sent to the refresh endpoint — not every request
};

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

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password required' });
  }

  const user = await db.query(
    'SELECT * FROM users WHERE email = $1',
    [email.toLowerCase().trim()]
  );

  if (!user.rows[0]) {
    // Use a constant-time comparison even when user doesn't exist
    // to prevent timing attacks that reveal whether an email is registered
    await bcrypt.compare(password, '$2b$12$invalidhashforfaketiming');
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const valid = await bcrypt.compare(password, user.rows[0].password_hash);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = generateAccessToken(user.rows[0]);
  const refreshToken = generateRefreshToken(user.rows[0]);

  // Store refresh token hash in DB — not the token itself
  const tokenHash = await bcrypt.hash(refreshToken, 10);
  await db.query(
    'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'7 days\')',
    [user.rows[0].id, tokenHash]
  );

  // Refresh token goes in httpOnly cookie
  res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);

  // Access token goes in response body — client stores in memory only
  res.json({
    accessToken,
    user: {
      id: user.rows[0].id,
      email: user.rows[0].email,
      role: user.rows[0].role,
    },
  });
});

Storing a hash of the refresh token in the database means that even if someone reads your database, they cannot use those tokens. The tradeoff is a bcrypt comparison on every refresh — acceptable given how rarely refresh happens.


Step 4 — Auth Middleware

// src/middleware/authenticate.js
import { verifyAccessToken } from '../lib/tokens.js';

export function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

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

  const token = authHeader.split(' ')[1];

  try {
    const payload = verifyAccessToken(token);

    if (payload.type !== 'access') {
      return res.status(401).json({ error: 'Invalid token type' });
    }

    req.user = {
      id: payload.sub,
      role: payload.role,
    };

    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Role-based access control middleware
export function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

Usage:

// Protected route — any authenticated user
router.get('/profile', authenticate, profileHandler);

// Admin only
router.delete('/users/:id', authenticate, requireRole('admin'), deleteUserHandler);

// Multiple roles
router.post('/posts', authenticate, requireRole('admin', 'editor'), createPostHandler);

Step 5 — Token Refresh

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

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

  let payload;
  try {
    payload = verifyRefreshToken(token);
  } catch (err) {
    res.clearCookie('refreshToken', { path: '/auth/refresh' });
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  if (payload.type !== 'refresh') {
    return res.status(401).json({ error: 'Invalid token type' });
  }

  // Look up stored refresh tokens for this user
  const storedTokens = await db.query(
    'SELECT * FROM refresh_tokens WHERE user_id = $1 AND expires_at > NOW()',
    [payload.sub]
  );

  // Verify the token matches one of the stored hashes
  let validToken = null;
  for (const stored of storedTokens.rows) {
    const match = await bcrypt.compare(token, stored.token_hash);
    if (match) {
      validToken = stored;
      break;
    }
  }

  if (!validToken) {
    // Token not found — possible token reuse attack
    // Invalidate ALL sessions for this user as a precaution
    await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [payload.sub]);
    res.clearCookie('refreshToken', { path: '/auth/refresh' });
    return res.status(401).json({ error: 'Token reuse detected' });
  }

  // Get fresh user data
  const user = await db.query('SELECT * FROM users WHERE id = $1', [payload.sub]);
  if (!user.rows[0]) {
    return res.status(401).json({ error: 'User not found' });
  }

  // Rotate refresh token — old one invalidated, new one issued
  await db.query('DELETE FROM refresh_tokens WHERE id = $1', [validToken.id]);

  const newAccessToken = generateAccessToken(user.rows[0]);
  const newRefreshToken = generateRefreshToken(user.rows[0]);

  const newTokenHash = await bcrypt.hash(newRefreshToken, 10);
  await db.query(
    'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'7 days\')',
    [user.rows[0].id, newTokenHash]
  );

  res.cookie('refreshToken', newRefreshToken, REFRESH_COOKIE_OPTIONS);
  res.json({ accessToken: newAccessToken });
});

Token rotation is the key here. Every refresh invalidates the old token and issues a new one. If someone steals a refresh token and tries to use it after the legitimate user has already refreshed, the mismatch triggers a full session wipe. This is the closest you can get to stateless revocation without a token blocklist.


Step 6 — Logout

router.post('/auth/logout', authenticate, async (req, res) => {
  // Delete all refresh tokens for this user
  await db.query(
    'DELETE FROM refresh_tokens WHERE user_id = $1',
    [req.user.id]
  );

  res.clearCookie('refreshToken', { path: '/auth/refresh' });
  res.json({ success: true });
});

// Logout all devices
router.post('/auth/logout-all', authenticate, async (req, res) => {
  await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user.id]);
  // Also increment tokenVersion to invalidate any tokens not in DB
  await db.query('UPDATE users SET token_version = token_version + 1 WHERE id = $1', [req.user.id]);

  res.clearCookie('refreshToken', { path: '/auth/refresh' });
  res.json({ success: true });
});

Database Schema

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  TEXT NOT NULL,
  expires_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  -- Optional: track device/browser for "sessions" UI
  user_agent  TEXT,
  ip_address  INET
);

CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at);

-- Clean up expired tokens periodically
-- Add this to a cron job or scheduled task:
-- DELETE FROM refresh_tokens WHERE expires_at < NOW();

The Things Most Tutorials Skip

Do not store access tokens in localStorage. XSS can steal them. Store in memory (a variable) — the token is lost on page refresh, which is exactly why refresh tokens exist.

Do not skip the timing-safe comparison on login. A login that returns faster for non-existent users than wrong-password users reveals your user database to an enumerator.

Short access token TTL is not optional. 15 minutes means a stolen access token is only dangerous for 15 minutes. Some tutorials use 24-hour access tokens because it is simpler. It is also indefensible.

The alg:none attack is real. Always whitelist algorithms in your verify call. No exceptions.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design