๐ŸŒ Detecting your locationโ€ฆ
๐Ÿ“ข Advertisement โ€” Configure AdSense in Appearance โ†’ Customize โ†’ AdSense Settings

How to Implement JWT Authentication in Express.js: Step-by-Step 2026 Guide

โฑ๏ธ6 min read  ยท  1,170 words

JWT (JSON Web Token) authentication is the standard for securing REST APIs in Node.js applications. In 2026, the implementation patterns are well-established โ€” but many tutorials skip critical security details like refresh token rotation, proper secret management, and token invalidation. This guide covers the complete, production-ready implementation.

๐Ÿ”‘ Key Takeaway

JWT (JSON Web Token) authentication is the standard for securing REST APIs in Node.js applications. In 2026, the implementation patterns are well-established โ€” but many tutorials skip critical security details like refresh token rotation, proper s…

How JWT Authentication Works

  1. User logs in with credentials โ†’ server validates them
  2. Server issues two tokens: access token (short-lived, 15min) and refresh token (long-lived, 7 days)
  3. Client sends access token in Authorization header for each request
  4. When access token expires, client sends refresh token to get a new access token
  5. On logout, refresh token is invalidated server-side

Setup

npm install express jsonwebtoken bcryptjs cookie-parser ioredis
npm install -D @types/jsonwebtoken @types/bcryptjs

Token Service

// services/tokenService.js
const jwt = require('jsonwebtoken');
const Redis = require('ioredis');

const redis = new Redis(process.env.REDIS_URL);

const ACCESS_SECRET  = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// CRITICAL: Use different secrets for access and refresh tokens

function generateAccessToken(payload) {
  return jwt.sign(payload, ACCESS_SECRET, {
    expiresIn:  '15m',
    issuer:     'myapp',
    audience:   'myapp-users',
  });
}

function generateRefreshToken(payload) {
  return jwt.sign(payload, REFRESH_SECRET, {
    expiresIn: '7d',
    issuer:    'myapp',
    audience:  'myapp-users',
    jwtid:     crypto.randomUUID(),  // unique ID for invalidation
  });
}

function verifyAccessToken(token) {
  return jwt.verify(token, ACCESS_SECRET, {
    issuer:   'myapp',
    audience: 'myapp-users',
  });
}

async function verifyRefreshToken(token) {
  const payload = jwt.verify(token, REFRESH_SECRET, {
    issuer:   'myapp',
    audience: 'myapp-users',
  });
  // Check token hasn't been revoked
  const isRevoked = await redis.get(`revoked:${payload.jti}`);
  if (isRevoked) throw new Error('Token revoked');
  return payload;
}

async function revokeRefreshToken(token) {
  try {
    const payload = jwt.decode(token);
    if (payload?.jti && payload?.exp) {
      const ttl = payload.exp - Math.floor(Date.now() / 1000);
      if (ttl > 0) {
        await redis.setex(`revoked:${payload.jti}`, ttl, '1');
      }
    }
  } catch (err) {
    // Ignore decode errors on logout
  }
}

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

Authentication Routes

// routes/auth.js
const express = require('express');
const bcrypt  = require('bcryptjs');
const router  = express.Router();
const { generateAccessToken, generateRefreshToken,
        verifyRefreshToken, revokeRefreshToken } = require('../services/tokenService');
const { findUserByEmail } = require('../db/users');

// POST /auth/login
router.post('/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 findUserByEmail(email);
  if (!user || !(await bcrypt.compare(password, user.hashedPassword))) {
    // Same error for missing user and wrong password (prevent user enumeration)
    return res.status(401).json({ error: 'Invalid credentials' });
  }

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

  // Store refresh token in httpOnly cookie (not accessible to JS)
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure:   process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge:   7 * 24 * 60 * 60 * 1000,  // 7 days in ms
  });

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

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

  try {
    const payload = await verifyRefreshToken(refreshToken);

    // Refresh token rotation: revoke old, issue new
    await revokeRefreshToken(refreshToken);
    const newRefreshToken = generateRefreshToken({
      sub: payload.sub, email: payload.email, role: payload.role
    });
    const newAccessToken = generateAccessToken({
      sub: payload.sub, email: payload.email, role: payload.role
    });

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure:   process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge:   7 * 24 * 60 * 60 * 1000,
    });
    res.json({ accessToken: newAccessToken });
  } catch (err) {
    res.clearCookie('refreshToken');
    res.status(401).json({ error: 'Invalid or expired refresh token' });
  }
});

// POST /auth/logout
router.post('/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (refreshToken) await revokeRefreshToken(refreshToken);
  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out successfully' });
});

module.exports = router;

Authentication Middleware

// middleware/authenticate.js
const { verifyAccessToken } = require('../services/tokenService');

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Access token required' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = verifyAccessToken(token);
    req.user = payload;  // { sub, email, role, iat, exp }
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Access token expired', code: 'TOKEN_EXPIRED' });
    }
    res.status(401).json({ error: 'Invalid access token' });
  }
}

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

module.exports = { authenticate, authorize };

Protecting Routes

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

// Public route
app.get('/health', (req, res) => res.json({ status: 'ok' }));

// Authenticated route
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ userId: req.user.sub, email: req.user.email });
});

// Role-restricted route
app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
  await deleteUser(req.params.id);
  res.json({ deleted: true });
});

Critical Security Checklist

  • Never store tokens in localStorage โ€” vulnerable to XSS. Use httpOnly cookies for refresh tokens.
  • Use separate secrets for access and refresh tokens. If the access secret leaks, refresh tokens remain safe.
  • Short access token expiry (15 minutes max). Long-lived access tokens can’t be invalidated without a denylist.
  • Refresh token rotation โ€” every refresh issues a new refresh token and revokes the old. Detects token theft.
  • HTTPS only in production โ€” JWTs in transit must be encrypted.
  • Validate iss and aud claims โ€” prevents tokens from one service being used in another.

Frequently Asked Questions

Q: Sessions vs JWT โ€” which is better?
A: Sessions (server-side state) are easier to invalidate immediately. JWTs are stateless and scale horizontally without a shared session store. Use sessions for simpler apps; JWT for APIs consumed by mobile/SPA clients or microservices.

Q: How do I invalidate a JWT before expiry?
A: Two approaches: (1) Maintain a Redis denylist of revoked token JTIs โ€” checked on every request. (2) Shorten access token expiry so the damage window is small. Option 1 is more secure; option 2 is simpler.

Q: Where should the access token be stored on the client?
A: In memory (JavaScript variable) โ€” not localStorage or sessionStorage. Memory storage means it’s lost on page refresh, requiring a refresh token call to get a new one โ€” that’s the intended behavior.

Q: Should I use RS256 or HS256?
A: HS256 (symmetric, shared secret) for services you fully control. RS256 (asymmetric, public/private keys) when third parties need to verify tokens without knowing your secret โ€” like microservices or partners.

Q: How do I handle “token expired” on the frontend?
A: Intercept 401 responses with a code: "TOKEN_EXPIRED" header, automatically call /auth/refresh, get a new access token, and retry the original request. Most HTTP clients (axios, ky) support request interceptors for this pattern.

Conclusion

Production JWT authentication requires more than just jwt.sign() and jwt.verify(). The complete implementation covers: httpOnly cookies for refresh tokens, token rotation on every refresh, Redis-backed revocation for logout, separate secrets for each token type, and role-based authorization middleware. This architecture scales horizontally (stateless access tokens), handles token theft (rotation detects reuse), and supports proper logout (revocation list). Start with this foundation and you won’t need to refactor your auth system later.

โœ๏ธ Leave a Comment

Your email address will not be published. Required fields are marked *

๐ŸŒ Read in:๐Ÿ‡ฌ๐Ÿ‡ง English๐Ÿ‡ฉ๐Ÿ‡ช Deutsch๐Ÿ‡ง๐Ÿ‡ท Portuguรชs๐Ÿ‡ธ๐Ÿ‡ฆ ุงู„ุนุฑุจูŠุฉ๐Ÿ‡ฎ๐Ÿ‡ณ เคนเคฟเคจเฅเคฆเฅ€๐Ÿ‡ง๐Ÿ‡ฉ เฆฌเฆพเฆ‚เฆฒเฆพ