JWT Authentication in Node.js and Express: Complete Security Guide

Learn how to implement secure JWT authentication in Node.js and Express. This complete guide covers signing tokens, access vs refresh tokens, httpOnly cookies, protected route middleware, token revocation, and the most critical security pitfalls to avoid in 2026.

JWT Authentication in Node.js and Express: Complete Security Guide

By Elhassane Mehdioui, Full Stack Web Developer — June 2026


Authentication is one of the most security-critical parts of any web application. Get it wrong and you expose your users to session hijacking, CSRF attacks, and data breaches. JSON Web Tokens (JWT) have become the dominant stateless authentication mechanism in modern Node.js and Express applications — but they come with sharp edges that trip up even experienced developers.

This JWT Node.js tutorial covers everything you need to build a production-grade auth system: how JWTs work under the hood, signing and verifying tokens with jsonwebtoken, the access/refresh token pattern, where to store tokens safely, writing Express middleware for protected routes, the full refresh flow, revocation strategies, and the security pitfalls that lead to real-world breaches.


How JWT Works

A JSON Web Token is a compact, URL-safe string that encodes a payload of claims. It has three Base64URL-encoded sections separated by dots:

header.payload.signature
  • Header — declares the algorithm (HS256, RS256, etc.) and token type.
  • Payload — the actual claims: sub (subject / user ID), iat (issued at), exp (expiry), and any custom fields you add.
  • Signature — an HMAC or RSA signature over the header and payload, produced with a secret or private key.

The server generates the token and sends it to the client. On every subsequent request the client sends the token back. The server verifies the signature — if it is valid and the token has not expired, the request is authenticated. No database lookup is required on every request, which is what makes JWTs "stateless."


Setting Up the Project

npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser dotenv
npm install -D nodemon

Create a .env file:

ACCESS_TOKEN_SECRET=your_super_secret_access_key_here
REFRESH_TOKEN_SECRET=your_super_secret_refresh_key_here
NODE_ENV=production

Never commit .env to version control. Use different, long random strings for each secret — generate them with openssl rand -hex 64.


Signing Tokens with jsonwebtoken

The jsonwebtoken package is the standard library for this JWT Node.js tutorial.

// utils/tokens.js
const jwt = require('jsonwebtoken');

function generateAccessToken(payload) {
  return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET, {
    expiresIn: '15m', // Short-lived
    algorithm: 'HS256',
  });
}

function generateRefreshToken(payload) {
  return jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET, {
    expiresIn: '7d',
    algorithm: 'HS256',
  });
}

function verifyAccessToken(token) {
  return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
}

function verifyRefreshToken(token) {
  return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
}

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken,
};

The payload should include the minimum necessary data — typically just the user ID and role. Never put passwords, credit card numbers, or sensitive PII in a JWT payload. Remember: the payload is Base64-encoded, not encrypted. Anyone who has the token can decode and read it.


Access Tokens vs Refresh Tokens

This is the most important architectural decision in a JWT authentication Express setup.

PropertyAccess TokenRefresh Token
LifespanShort (5–15 minutes)Long (7–30 days)
StorageMemory or httpOnly cookiehttpOnly cookie only
PurposeAuthorize API requestsObtain new access tokens
Sent with every requestYesNo — only to /auth/refresh

Why two tokens? A long-lived access token is a liability. If it is stolen, the attacker has access for its entire validity period and you have no way to stop them without invalidating everyone's sessions (because the system is stateless). By keeping access tokens short-lived, a stolen token has a narrow window of usefulness. The refresh token is only ever sent to one specific endpoint, reducing its attack surface.


httpOnly Cookies vs localStorage

This is where many JWT tutorials give dangerous advice. Storing tokens in localStorage exposes them to any JavaScript running on the page — including injected scripts from XSS attacks. localStorage has no built-in security boundary.

httpOnly cookies cannot be accessed by JavaScript at all. They are attached to requests automatically by the browser and are only readable by the server. Combined with the Secure and SameSite flags, they are the correct storage mechanism for tokens in a browser-based application.

// Setting cookies on login
res.cookie('accessToken', accessToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production', // HTTPS only
  sameSite: 'strict', // Prevents CSRF
  maxAge: 15 * 60 * 1000, // 15 minutes in ms
});

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

Setting path: '/auth/refresh' on the refresh token cookie means the browser will only include it in requests to that path — further reducing exposure.


The Login Endpoint

// routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const { generateAccessToken, generateRefreshToken } = require('../utils/tokens');
const User = require('../models/User'); // Your user model

const router = express.Router();

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

  try {
    const user = await User.findOne({ email });
    if (!user) return res.status(401).json({ message: 'Invalid credentials' });

    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) return res.status(401).json({ message: 'Invalid credentials' });

    const payload = { sub: user._id, role: user.role };
    const accessToken = generateAccessToken(payload);
    const refreshToken = generateRefreshToken(payload);

    // Store refresh token hash in DB for revocation
    await User.updateOne({ _id: user._id }, {
      refreshTokenHash: await bcrypt.hash(refreshToken, 10),
    });

    res.cookie('accessToken', accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000,
    });

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

    res.json({ message: 'Logged in successfully' });
  } catch (err) {
    res.status(500).json({ message: 'Server error' });
  }
});

module.exports = router;

Note that the error message is identical for both "user not found" and "wrong password" cases. This prevents user enumeration attacks.


Middleware for Protected Routes

Express middleware is the clean way to guard any route that requires authentication in a JWT authentication Express application.

// middleware/authenticate.js
const { verifyAccessToken } = require('../utils/tokens');

function authenticate(req, res, next) {
  const token = req.cookies.accessToken;

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

  try {
    const decoded = verifyAccessToken(token);
    req.user = decoded; // { sub, role, iat, exp }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ message: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(403).json({ message: 'Invalid token' });
  }
}

function authorize(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user?.role)) {
      return res.status(403).json({ message: 'Insufficient permissions' });
    }
    next();
  };
}

module.exports = { authenticate, authorize };

Using these middleware functions on routes is straightforward:

const { authenticate, authorize } = require('../middleware/authenticate');

// Any authenticated user
router.get('/profile', authenticate, (req, res) => {
  res.json({ userId: req.user.sub });
});

// Admin only
router.delete('/users/:id', authenticate, authorize('admin'), async (req, res) => {
  // ...
});

The Refresh Flow

When the access token expires, the client receives a 401 with code: 'TOKEN_EXPIRED'. It should then silently hit the refresh endpoint to get a new access token without asking the user to log in again.

// routes/auth.js (continued)
const { verifyRefreshToken } = require('../utils/tokens');

router.post('/refresh', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ message: 'No refresh token' });

  try {
    const decoded = verifyRefreshToken(token);
    const user = await User.findById(decoded.sub);

    if (!user || !user.refreshTokenHash) {
      return res.status(403).json({ message: 'Refresh token revoked' });
    }

    // Verify the stored hash matches (revocation check)
    const valid = await bcrypt.compare(token, user.refreshTokenHash);
    if (!valid) return res.status(403).json({ message: 'Invalid refresh token' });

    // Rotate: issue a new refresh token (refresh token rotation)
    const newPayload = { sub: user._id, role: user.role };
    const newAccessToken = generateAccessToken(newPayload);
    const newRefreshToken = generateRefreshToken(newPayload);

    await User.updateOne({ _id: user._id }, {
      refreshTokenHash: await bcrypt.hash(newRefreshToken, 10),
    });

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

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

    res.json({ message: 'Tokens refreshed' });
  } catch (err) {
    res.status(403).json({ message: 'Invalid or expired refresh token' });
  }
});

Refresh token rotation is key: every time a refresh token is used, it is replaced with a new one and the old one is invalidated. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail (their token was invalidated), alerting you to the compromise.


Token Revocation

Pure stateless JWTs cannot be revoked before expiry. The strategies to handle this are:

  1. Store a hash in the database (shown above) — check it on every refresh. This keeps access tokens fully stateless but still lets you revoke refresh tokens immediately.
  2. Token blocklist — maintain a Redis set of revoked token JTIs (jti claim). Check on every access token request. This makes access token revocation instant at the cost of a Redis lookup per request.
  3. Short expiry + rotation — rely on the 15-minute window and rotation to limit damage. Suitable for lower-risk applications.

For the logout endpoint:

router.post('/logout', authenticate, async (req, res) => {
  // Invalidate refresh token in DB
  await User.updateOne({ _id: req.user.sub }, { refreshTokenHash: null });

  res.clearCookie('accessToken');
  res.clearCookie('refreshToken', { path: '/auth/refresh' });
  res.json({ message: 'Logged out' });
});

Security Pitfalls to Avoid

These are the most common mistakes in JWT authentication Express implementations.

1. Using none algorithm. Early JWT libraries accepted alg: none, meaning no signature. Always explicitly specify the algorithm when verifying and reject tokens that specify a different one.

2. Weak secrets. A short or guessable secret enables offline brute-force attacks. Use a cryptographically random 256-bit (64 hex character) secret minimum.

3. Storing tokens in localStorage. As covered above, this exposes tokens to XSS. Use httpOnly cookies.

4. Missing expiry (exp claim). A JWT without an expiry is valid forever. Always set expiresIn.

5. Putting sensitive data in the payload. The payload is readable by anyone who has the token. Keep it minimal — user ID and role at most.

6. Not rotating refresh tokens. Without rotation, a stolen refresh token gives permanent access until the user explicitly logs out.

7. Ignoring SameSite on cookies. Without SameSite: strict or lax, cookies are vulnerable to CSRF attacks even when they are httpOnly.

8. Trusting the client-side role check. Always verify the role from the decoded token on the server. Never let the client pass a role in the request body and use that for authorization.


Wrapping Up

Building secure JWT authentication in Node.js requires more than calling jwt.sign(). The access/refresh token pattern with rotation, httpOnly cookies with Secure and SameSite flags, proper Express middleware, and a revocation strategy are the pillars of a production-grade JSON Web Token guide implementation.

The code in this guide gives you a solid foundation. The most important mindset shift is treating security as the default — short token lifetimes, server-side validation at every step, and the principle of least privilege in your payloads. Apply these practices and your Node.js auth 2026 implementation will hold up against the most common attack vectors.


Elhassane Mehdioui is a Full Stack Web Developer specializing in Node.js, React, and scalable web architectures.

Need the full picture?

Full-stack applications from database to UI.

MERN stack, Laravel + React, Spring Boot — complete solutions built end-to-end.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://