Back to blog
Authentication
Intermediate

JWT Authentication in Node.js and Express – Secure API Guide

This guide walks you through implementing JWT-based authentication in a Node.js and Express application. You'll learn token generation, validation, middleware integration, and production-ready security practices.

September 24, 2025

Introduction

Authentication is a foundational concern for any modern web application. When building APIs with Node.js and Express, developers often choose JSON Web Tokens (JWT) because they are stateless, compact, and easy to transmit over HTTP headers. This guide provides a complete walkthrough of implementing JWT‑based authentication from scratch, covering token creation, verification, middleware integration, refresh token strategy, and production‑grade security considerations.

By the end of this article you will have a ready‑to‑use authentication layer that you can drop into any Express project, along with a clear understanding of the trade‑offs involved and how to avoid common pitfalls.

Table of Contents

Core Concepts

Before diving into code, it is essential to understand the building blocks of JWT authentication.

What is a JSON Web Token?

A JSON Web Token is an open standard (RFC 7519) that defines a compact, URL‑safe means of representing claims between two parties. The token consists of three Base64URL‑encoded parts separated by dots: header, payload, and signature.

Header

The header typically declares the token type (JWT) and the signing algorithm, such as HS256 (HMAC SHA‑256) or RS256 (RSA SHA‑256). Example:

{  "alg": "HS256",  "typ": "JWT"}

Payload

The payload contains the claims. Claims are statements about an entity (typically the user) and additional data. There are three types: registered, public, and private claims. Registered claims include iss (issuer), sub (subject), aud (audience), exp (expiration time), nbf (not before), and iat (issued at). A minimal payload for authentication might look like:

{  "sub": "1234567890",  "name": "Jane Doe",  "iat": 1516239022,  "exp": 1816239022}

Signature

To create the signature you take the encoded header, the encoded payload, a secret, and the algorithm specified in the header. The signature ensures that the token hasn't been altered after issuance.

Why JWT for APIs?

  • Stateless: No server‑side session storage is required; the token carries all necessary information.
  • Compact: Tokens can be sent via HTTP headers or URL parameters.
  • Interoperable: JWTs are language‑agnostic and widely supported.
  • Extensible: You can add custom claims to meet business needs.

Potential Drawbacks

  • Token size grows with added claims; large tokens can impact request overhead.
  • If the signing key is compromised, attackers can forge tokens.
  • Revocation is not inherent; you need a blacklist or short expiration with refresh tokens.

Architecture Overview

In a typical Express API using JWT, the authentication flow consists of the following components:

  1. Login Endpoint – Accepts credentials, validates them against a user store, and returns a signed access token (and optionally a refresh token).
  2. Access Token – Short‑lived JWT sent in the Authorization: Bearer <token> header on each protected request.
  3. Authentication Middleware – Verifies the token's signature, checks expiration, and attaches the decoded payload to req.user.
  4. Refresh Token Endpoint – Allows clients to obtain a new access token using a long‑lived refresh token stored securely (e.g., HttpOnly cookie).
  5. Logout / Token Blacklist – Optional mechanism to invalidate tokens before expiration.

The diagram below illustrates the flow (conceptual):

Client --> POST /login (username/password) --> ServerServer --> verify credentials --> generate access + refresh tokens --> ClientClient --> GET /protected (Authorization: Bearer access) --> ServerServer --> middleware verify token --> grant/deny access --> ClientClient --> POST /refresh (refresh token) --> ServerServer --> validate refresh token --> issue new access token --> Client

In practice, the refresh token is often stored in an HttpOnly, Secure cookie to mitigate XSS risks, while the access token is kept in memory or localStorage (with awareness of XSS trade‑offs).

Step‑by‑Step Guide

We will now build a minimal but functional JWT authentication system in an Express application. The steps assume you have Node.js ≥18 and npm installed.

1. Initialize the Project

mkdir jwt-auth-democd jwt-auth-demonpm init -ynpm install express jsonwebtoken bcryptjs dotenvnpm install --save-dev nodemon

Create a .env file to store secrets:

ACCESS_TOKEN_SECRET=your_strong_random_string_hereREFRESH_TOKEN_SECRET=another_strong_random_string_herePORT=3000

2. Set Up the Server

Create index.js:

require('dotenv').config();const express = require('express');const jwt = require('jsonwebtoken');const bcrypt = require('bcryptjs');const app = express();app.use(express.json());// In‑memory user store (for demo only)const users = [  { id: 1, username: 'alice', password: bcrypt.hashSync('secret123', 8) }];function authenticateToken(req, res, next) {  const authHeader = req.headers['authorization'];  const token = authHeader && authHeader.split(' ')[1];  if (!token) return res.sendStatus(401);  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {    if (err) return res.sendStatus(403);    req.user = user;    next();  });}app.post('/login', (req, res) => {  const { username, password } = req.body;  const user = users.find(u => u.username === username);  if (!user) return res.status(400).send('Invalid username or password');  const passwordMatch = bcrypt.compareSync(password, user.password);  if (!passwordMatch) return res.status(400).send('Invalid username or password');  const accessToken = jwt.sign({ sub: user.id, username: user.username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });  const refreshToken = jwt.sign({ sub: user.id, username: user.username }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' });  // Store refresh token (in production use a DB or Redis)  user.refreshToken = refreshToken;  res.json({ accessToken, refreshToken });});app.get('/protected', authenticateToken, (req, res) => {  res.json({ message: `Hello ${req.user.username}, you have accessed a protected route!` });});app.post('/refresh', (req, res) => {  const { token: refreshToken } = req.body;  if (!refreshToken) return res.sendStatus(401);  const user = users.find(u => u.refreshToken === refreshToken);  if (!user) return res.sendStatus(403);  jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, userPayload) => {    if (err) return res.sendStatus(403);    const newAccessToken = jwt.sign({ sub: userPayload.id, username: userPayload.username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });    res.json({ accessToken: newAccessToken });  });});app.listen(process.env.PORT, () => console.log(`Server running on port ${process.env.PORT}`));

3. Explain the Code

  • dotenv loads environment variables.
  • bcryptjs hashes passwords; never store plain text.
  • The /login route checks credentials, then signs an access token (15 min expiry) and a refresh token (7 day expiry).
  • The authenticateToken middleware extracts the Bearer token, verifies it with the access token secret, and attaches the decoded payload to req.user.
  • The /protected route demonstrates a route that requires a valid token.
  • The /refresh route validates the refresh token and issues a new access token, enabling silent token renewal.

4. Testing the Flow

Start the server with npx nodemon index.js. Use a tool like curl or Postman:

# Logincurl -X POST http://localhost:3000/login \  -H "Content-Type: application/json" \  -d '{"username":"alice","password":"secret123"}'# Suppose you received:# {"accessToken":"eyJ...","refreshToken":"eyJ..."}# Access protected routecurl -X GET http://localhost:3000/protected \  -H "Authorization: Bearer eyJ..."# Refresh tokencurl -X POST http://localhost:3000/refresh \  -H "Content-Type: application/json" \  -d '{"token":"eyJ..."}'

Real‑World Examples

Beyond the minimal demo, production systems often need additional features. Below are common enhancements.

Example 1: Role‑Based Access Control (RBAC)

Add a role claim to the token and check it in middleware:

function authorizeRole(role) {  return (req, res, next) => {    if (req.user.role !== role) {      return res.status(403).send('Insufficient permissions');    }    next();  };}app.get('/admin', authenticateToken, authorizeRole('admin'), (req, res) => {  res.json({ message: 'Admin only data' });});

When issuing the token, include the role:

const accessToken = jwt.sign(  { sub: user.id, username: user.username, role: user.role },  process.env.ACCESS_TOKEN_SECRET,  { expiresIn: '15m' });

Example 2: Token Blacklist with Redis

To support immediate logout, store revoked tokens (or their JTI) in Redis with a TTL matching the token's remaining life.

const redis = require('redis');const client = redis.createClient({ url: process.env.REDIS_URL });client.connect().catch(console.error);async function isTokenBlacklisted(jti) {  const val = await client.get(`bl_${jti}`);  return val !== null;}async function blacklistToken(jti, exp) {  const ttl = exp - Math.floor(Date.now() / 1000);  if (ttl > 0) await client.setEx(`bl_${jti}`, ttl, '1');}// In logout routeapp.post('/logout', authenticateToken, (req, res) => {  const token = req.headers.authorization.split(' ')[1];  const decoded = jwt.decode(token);  blacklistToken(decoded.jti, decoded.exp).then(() => {    res.sendStatus(200);  }).catch(() => res.sendStatus(500));});// Extend authentication middlewarefunction authenticateToken(req, res, next) {  const authHeader = req.headers['authorization'];  const token = authHeader && authHeader.split(' ')[1];  if (!token) return res.sendStatus(401);  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, async (err, user) => {    if (err) return res.sendStatus(403);    const decoded = jwt.decode(token);    if (await isTokenBlacklisted(decoded.jti)) return res.sendStatus(401);    req.user = user;    next();  });}

Example 3: HttpOnly Refresh Token Cookie

Instead of sending the refresh token in the response body, set it as an HttpOnly cookie:

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

Then read it from req.cookies in the refresh endpoint (requires cookie-parser middleware).

Production Code Examples

The following snippets show a more robust setup suitable for a real project.

1. Centralized JWT Utility

// utils/jwt.jsconst jwt = require('jsonwebtoken');require('dotenv').config();function signAccessToken(payload) {  return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });}function signRefreshToken(payload) {  return jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' });}function verifyAccessToken(token) {  return new Promise((resolve, reject) => {    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, payload) => {      if (err) return reject(err);      resolve(payload);    });  });}function verifyRefreshToken(token) {  return new Promise((resolve, reject) => {    jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, payload) => {      if (err) return reject(err);      resolve(payload);    });  });}module.exports = { signAccessToken, signRefreshToken, verifyAccessToken, verifyRefreshToken };

2. Authentication Middleware with Error Handling

// middleware/auth.jsconst { verifyAccessToken } = require('../utils/jwt');async function authenticate(req, res, next) {  const auth = req.headers.authorization;  if (!auth || !auth.startsWith('Bearer ')) {    return res.status(401).json({ error: 'Missing or malformed token' });  }  const token = auth.split(' ')[1];  try {    const payload = await verifyAccessToken(token);    req.user = payload;    next();  } catch (err) {    return res.status(403).json({ error: 'Invalid or expired token' });  }}module.exports = { authenticate };

3. Refresh Token Route Using HttpOnly Cookie

// routes/auth.jsconst express = require('express');const router = express.Router();const cookieParser = require('cookie-parser');const { signAccessToken, signRefreshToken, verifyRefreshToken } = require('../utils/jwt');router.use(cookieParser());router.post('/login', async (req, res) => {  const { username, password } = req.body;  // Assume getUserByUsername returns a user object with hashed password  const user = await getUserByUsername(username);  if (!user || !(await bcrypt.compare(password, user.hash))) {    return res.status(400).json({ error: 'Invalid credentials' });  }  const accessToken = signAccessToken({ sub: user.id, username: user.username });  const refreshToken = signRefreshToken({ sub: user.id, username: user.username });  // Store refresh token hash in DB (not shown)  await storeRefreshTokenHash(user.id, refreshToken);  res.cookie('refreshToken', refreshToken, {    httpOnly: true,    secure: process.env.NODE_ENV === 'production',    sameSite: 'strict',    maxAge: 7 * 24 * 60 * 60 * 1000  });  res.json({ accessToken });});router.post('/refresh', async (req, res) => {  const refreshToken = req.cookies.refreshToken;  if (!refreshToken) return res.status(401).json({ error: 'Refresh token missing' });  const tokenDoc = await findRefreshTokenByToken(refreshToken);  if (!tokenDoc) return res.status(403).json({ error: 'Invalid refresh token' });  try {    const payload = await verifyRefreshToken(refreshToken);    const newAccessToken = signAccessToken({ sub: payload.sub, username: payload.username });    res.json({ accessToken: newAccessToken });  } catch (err) {    res.status(403).json({ error: 'Invalid refresh token' });  }});router.post('/logout', async (req, res) => {  const refreshToken = req.cookies.refreshToken;  await deleteRefreshToken(refreshToken);  res.clearCookie('refreshToken', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' });  res.sendStatus(200);});module.exports = router;

Comparison Table

When deciding on an authentication strategy, it helps to compare JWT with traditional server‑side sessions and with opaque tokens. The table below highlights key aspects.

FeatureJWT (Stateless)Server‑Side SessionsOpaque Reference Tokens
Storage RequirementNone on server (token holds data)Session store (Redis, DB)Token‑to‑session mapping store
Token SizeMedium (claims + signature)Small (session ID)Small (random ID)
RevocationNeeds blacklist or short expiryImmediate (delete session)Immediate (delete mapping)
ScalabilityHigh (no server affinity)Medium (shared store needed)Medium (shared store needed)
Security RisksToken theft, key compromiseSession fixation, side‑channelToken theft, mapping exposure
Typical Use CaseAPIs, microservices, SPATraditional web appsWhen token introspection is required

Best Practices

  • Use Strong Secrets: Generate sufficiently random strings (at least 32 bytes) for ACCESS_TOKEN_SECRET and REFRESH_TOKEN_SECRET. Consider using environment‑managed secrets or a key management service.
  • Short‑Lived Access Tokens: 10‑15 minutes is a common sweet spot; reduces the window of token misuse.
  • HttpOnly, Secure Cookies for Refresh Tokens: Prevents XSS access and ensures transmission only over HTTPS.
  • HTTPS Everywhere: Never transmit tokens over plain HTTP.
  • Avoid Sensitive Data in Payload: Remember that the JWT payload is base64 encoded, not encrypted. Do not store passwords, API keys, or PII.
  • Validate Audience and Issuer: If your system issues tokens for multiple audiences (e.g., different services), verify aud and iss claims.
  • Implement Refresh Token Rotation: On each refresh, issue a new refresh token and invalidate the old one to limit replay attacks.
  • Use Established Libraries: Prefer well‑maintained packages like jsonwebtoken or jose; avoid rolling your own crypto.
  • Log Authentication Events: Track logins, token issuances, refreshes, and failures for anomaly detection.
  • Apply Rate Limiting: Protect login and refresh endpoints against brute‑force attacks.

Common Mistakes

  • Storing Tokens in localStorage: While convenient for SPA, it exposes tokens to XSS. Prefer HttpOnly cookies for refresh tokens and keep access tokens in memory.
  • Using Weak Secrets: Hard‑coded or predictable strings make token forgery trivial.
  • Long Expiration for Access Tokens: Increases risk if token is leaked.
  • Missing Token Validation: Forgetting to verify signature, expiration, or required claims.
  • Not Rotating Refresh Tokens: Allows stolen refresh tokens to be reused indefinitely.
  • Putting Secrets in Client‑Side Code: Never embed signing secrets in frontend bundles.
  • Ignoring Token Revocation Needs: Assuming JWTs are instantly revocable leads to security gaps.
  • Overloading Payload with Large Claims: Bloated tokens increase request size and latency.
  • Using HS256 with Public Key Misconfiguration: Confusing symmetric and asymmetric algorithms can cause verification failures.
  • Not Using HTTPS: Transmitting tokens over HTTP enables man‑in‑the‑middle interception.

Performance Tips

  • Cache Public Keys: If using RS256, cache the JWKS (JSON Web Key Set) to avoid repeated network fetches.
  • Use Asymmetric Algorithms for Distributed Systems: RS256 or ES256 allows anyone to verify with a public key while only the issuer can sign.
  • Minimize Claims: Only include essential data; each extra claim increases CPU for signing/verifying and network overhead.
  • Batch Token Verification: In high‑throughput gateways, consider verifying signatures in batches using crypto‑accelerated libraries.
  • Leverage Hardware Acceleration: Modern CPUs support AES‑NI and SHA extensions that speed up HMAC.
  • Monitor Token Size: Keep average token size under 1 KB to avoid impacting HTTP header limits.
  • Use Connection Pooling for DB/Redis: When storing refresh token hashes, reuse connections.

Security Considerations

  • Algorithm Confusion Attacks: Always explicitly specify the expected algorithm when verifying (jwt.verify(token, secret, { algorithms: ['HS256'] })). Never accept none algorithm.
  • Key Management: Rotate signing keys periodically and support key ID (kid) claims to enable smooth transitions.
  • Token Binding: Consider binding tokens to client properties (e.g., TLS certificate hash or IP) to reduce replay risk.
  • CSRF Protection for Cookie‑Based Refresh Tokens: Use SameSite attributes and anti‑CSRF tokens for state‑changing operations.
  • Audience Validation: Ensure the token's aud matches your service identifier.
  • Issuer Validation: Confirm iss matches your expected issuer.
  • Timeouts on Crypto Operations: Set timeouts to avoid DoS via extremely large tokens.
  • Secure Secret Storage: Use Docker secrets, Kubernetes secrets, AWS Secrets Manager, or HashiCorp Vault.
  • Limit Token Usage Scope: If possible, restrict tokens to specific endpoints or methods via scopes (scope claim).

Deployment Notes

  • Environment Variables: Never commit .env to version control. Use platform‑specific secret injection (e.g., Heroku config vars, AWS ECS task definitions).
  • Containerization: When packaging the app in Docker, expose only the necessary port (e.g., 3000) and run as a non‑root user.
  • Reverse Proxy: Place the Express app behind Nginx or Cloudflare to terminate TLS, enforce rate limiting, and add security headers.
  • Health Checks: Expose a /health endpoint that returns 200 when the service is up; optionally verify DB/Redis connectivity.
  • Auto Scaling: Stateless JWT verification makes horizontal scaling straightforward; ensure session stores (if used for refresh tokens) are replicated.
  • Monitoring: Track metrics like token issuance rate, validation failures, and latency.
  • Logging: Avoid logging full tokens; log only the token ID (jti) or a hashed version.

Debugging Tips

  • Decode Tokens: Use jwt.io or the command line (node -e "console.log(require('jsonwebtoken').decode(token))") to inspect header and payload.
  • Check Expiry: Compare exp claim with current time; remember it's Unix seconds.
  • Verify Secrets: Ensure you're using the same secret for signing and verification.
  • Look for Typos in Header: A misspelled alg leads to verification failure.
  • Inspect Middleware Order: Ensure authentication middleware runs before route handlers that require req.user.
  • Test with Invalid Tokens: Send malformed, expired, or wrong‑secret tokens to confirm proper error responses (401/403).
  • Check Cookie Settings: If using HttpOnly cookies, verify they're being sent back on requests (look at the Cookie header).
  • Review Logs: Many libraries emit debug messages when DEBUG=jsonwebtoken* is set.

FAQ

Q1: Should I store the JWT in localStorage or cookies?

Access tokens are short‑lived and can be kept in memory (e.g., Redux store) to mitigate XSS. Refresh tokens, which are longer lived, should be stored in HttpOnly, Secure cookies to prevent JavaScript access.

Q2: How do I handle token expiration gracefully?

Intercept 401 responses from protected endpoints. If the error indicates an expired token, silently call the refresh endpoint to obtain a new access token and retry the original request.

Q3: Is it safe to use the same secret for access and refresh tokens?

It is technically possible but not recommended. Using separate secrets limits the impact if one is compromised.

Q4: What is the JWT