How to Implement JWT Authentication in a Node.js REST API: A Practical Guide

How to Implement JWT Authentication in a Node.js REST API: A Practical Guide

by | Aug 6, 2026 | Uncategorized | 0 comments

Introduction

Building a secure REST API is one of the most common challenges backend developers face. JWT authentication in Node.js has become the industry standard for stateless, scalable authentication in modern applications. But most tutorials online show toy examples that would never survive a security audit in production.

In this practical guide, we’ll walk through building a complete JWT authentication system in an Express.js REST API, covering token generation, middleware verification, refresh token rotation, and the security best practices you actually need in 2026.

Why JWT Authentication for Node.js APIs?

JSON Web Tokens (JWT) provide a stateless authentication mechanism. Unlike session-based auth, the server does not need to store session data, which makes JWTs ideal for:

  • Microservices architectures where multiple services need to validate identity
  • Mobile and SPA applications that consume REST or GraphQL APIs
  • Horizontally scaled deployments where sticky sessions become a burden
  • Third-party integrations requiring short-lived, verifiable credentials

JWT vs Session-Based Authentication

Aspect JWT Session
Storage Client-side Server-side
Scalability Excellent Requires shared store
Revocation Complex (needs blacklist) Immediate
Payload size Larger Small (just an ID)
node js code security

Project Setup

Let’s start by initializing a new Node.js project with the dependencies we need.

1. Install Dependencies

npm init -y
npm install express jsonwebtoken bcrypt dotenv cookie-parser helmet express-rate-limit
npm install --save-dev nodemon

2. Environment Variables

Create a .env file. Never hardcode secrets in your source code:

PORT=3000
ACCESS_TOKEN_SECRET=your_very_long_random_string_at_least_64_chars
REFRESH_TOKEN_SECRET=another_completely_different_random_string_64_chars
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
NODE_ENV=development

Pro tip: Generate strong secrets using node -e "console.log(require('crypto').randomBytes(64).toString('hex'))".

Step 1: Building the Token Service

Separate token logic from your route handlers. Create services/tokenService.js:

const jwt = require('jsonwebtoken');

const generateAccessToken = (user) => {
  return jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    process.env.ACCESS_TOKEN_SECRET,
    {
      expiresIn: process.env.ACCESS_TOKEN_EXPIRY,
      issuer: 'graphiteone-api',
      audience: 'graphiteone-clients'
    }
  );
};

const generateRefreshToken = (user, tokenId) => {
  return jwt.sign(
    { sub: user.id, jti: tokenId },
    process.env.REFRESH_TOKEN_SECRET,
    {
      expiresIn: process.env.REFRESH_TOKEN_EXPIRY,
      issuer: 'graphiteone-api'
    }
  );
};

const verifyAccessToken = (token) => {
  return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
    issuer: 'graphiteone-api',
    audience: 'graphiteone-clients'
  });
};

const verifyRefreshToken = (token) => {
  return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, {
    issuer: 'graphiteone-api'
  });
};

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

Why Two Different Secrets?

Using separate secrets for access and refresh tokens is a critical security practice. If your access token secret is compromised (for example through a memory dump), attackers still cannot forge refresh tokens to maintain long-term access.

node js code security

Step 2: Registration and Login Endpoints

Create controllers/authController.js:

const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { generateAccessToken, generateRefreshToken } = require('../services/tokenService');
const User = require('../models/User');
const RefreshToken = require('../models/RefreshToken');

exports.register = async (req, res) => {
  try {
    const { email, password } = req.body;

    if (!email || !password || password.length < 12) {
      return res.status(400).json({
        error: 'Invalid input. Password must be at least 12 characters.'
      });
    }

    const existing = await User.findOne({ email });
    if (existing) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    const hashedPassword = await bcrypt.hash(password, 12);
    const user = await User.create({ email, password: hashedPassword, role: 'user' });

    return res.status(201).json({ id: user.id, email: user.email });
  } catch (err) {
    return res.status(500).json({ error: 'Registration failed' });
  }
};

exports.login = async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email });

    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const tokenId = crypto.randomUUID();
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user, tokenId);

    await RefreshToken.create({
      tokenId,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });

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

    return res.json({ accessToken });
  } catch (err) {
    return res.status(500).json({ error: 'Login failed' });
  }
};

Step 3: The Authentication Middleware

Create middleware/authenticate.js:

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

module.exports = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid Authorization header' });
  }

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

  try {
    const payload = verifyAccessToken(token);
    req.user = {
      id: payload.sub,
      email: payload.email,
      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' });
  }
};

Adding Role-Based Access Control

module.exports.requireRole = (...allowedRoles) => {
  return (req, res, next) => {
    if (!req.user || !allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
};

Step 4: Refresh Token Rotation

Refresh token rotation is one of the most important security patterns in modern JWT implementations. Every time a refresh token is used, it is invalidated and a new one is issued. If a stolen refresh token is used, the legitimate user’s next refresh attempt will fail, alerting the system to compromise.

exports.refresh = async (req, res) => {
  const token = req.cookies.refreshToken;

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

  try {
    const payload = verifyRefreshToken(token);
    const storedToken = await RefreshToken.findOne({ tokenId: payload.jti });

    if (!storedToken || storedToken.revoked) {
      // Possible token reuse attack: revoke all tokens for this user
      await RefreshToken.updateMany(
        { userId: payload.sub },
        { revoked: true }
      );
      return res.status(401).json({ error: 'Refresh token invalidated' });
    }

    // Rotate: revoke old token, create new one
    storedToken.revoked = true;
    await storedToken.save();

    const user = await User.findById(payload.sub);
    const newTokenId = crypto.randomUUID();
    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user, newTokenId);

    await RefreshToken.create({
      tokenId: newTokenId,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });

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

    return res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
};
node js code security

Step 5: Logout and Token Revocation

exports.logout = async (req, res) => {
  const token = req.cookies.refreshToken;

  if (token) {
    try {
      const payload = verifyRefreshToken(token);
      await RefreshToken.updateOne(
        { tokenId: payload.jti },
        { revoked: true }
      );
    } catch (err) {
      // Silent fail on invalid token during logout
    }
  }

  res.clearCookie('refreshToken', { path: '/api/auth' });
  return res.json({ message: 'Logged out successfully' });
};

Step 6: Wiring Everything Together

In your main app.js:

const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
require('dotenv').config();

const authController = require('./controllers/authController');
const authenticate = require('./middleware/authenticate');

const app = express();

app.use(helmet());
app.use(express.json({ limit: '10kb' }));
app.use(cookieParser());

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: 'Too many attempts, please try again later'
});

app.post('/api/auth/register', authLimiter, authController.register);
app.post('/api/auth/login', authLimiter, authController.login);
app.post('/api/auth/refresh', authController.refresh);
app.post('/api/auth/logout', authController.logout);

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

app.listen(process.env.PORT, () => {
  console.log(`Server running on port ${process.env.PORT}`);
});

Security Best Practices Checklist

Before shipping your JWT authentication to production, verify each of these items:

  1. Short-lived access tokens: Keep them between 5 and 15 minutes
  2. HttpOnly cookies for refresh tokens: Prevents XSS from stealing them
  3. SameSite=strict: Blocks most CSRF attack vectors
  4. Refresh token rotation: Every refresh invalidates the old token
  5. Server-side revocation store: Keep refresh token metadata in a database
  6. Strong secrets: At least 256 bits of entropy, stored in a secrets manager
  7. Rate limiting: On login, register, and refresh endpoints
  8. Password hashing: Use bcrypt with cost factor 12 or higher
  9. HTTPS only: Never send tokens over plain HTTP in production
  10. Validate issuer and audience: Prevents cross-service token replay
  11. Never store JWTs in localStorage: Vulnerable to XSS
  12. Log security events: Failed logins, token reuse attempts, revocations
node js code security

Common Mistakes to Avoid

  • Using the none algorithm: Always explicitly specify HS256 or RS256
  • Storing sensitive data in JWT payload: Payloads are only encoded, not encrypted
  • Long-lived access tokens: Creates a large window for token theft exploitation
  • Skipping refresh token storage: Without it, you cannot revoke access
  • Using the same secret across environments: Development and production must be isolated

Testing Your Implementation

Use tools like Postman, Insomnia, or automated tests with Jest and Supertest to validate:

  • Successful login returns access token and sets refresh cookie
  • Protected routes reject requests without a valid token
  • Expired tokens return 401 with proper error code
  • Refresh endpoint issues new tokens and invalidates old ones
  • Reused refresh tokens trigger account-wide revocation
  • Logout properly clears server-side and client-side state

Frequently Asked Questions

Should I use JWT or sessions for my Node.js API?

Use JWT when you need stateless authentication across microservices or mobile clients. Use sessions when you have a traditional web app with a single server or need instant token revocation without extra infrastructure.

Where should I store JWTs on the client?

Store the access token in memory (JavaScript variable or state manager) and the refresh token in an HttpOnly, Secure, SameSite=strict cookie. Avoid localStorage since it is vulnerable to XSS attacks.

How long should JWT access tokens live?

Between 5 and 15 minutes is the sweet spot. Short enough to limit damage if stolen, long enough to avoid constant refresh requests degrading user experience.

Can JWTs be revoked?

Access tokens cannot be revoked once issued (they are stateless by design), which is why they should be short-lived. Refresh tokens can and should be revoked through a server-side store like Redis or a database.

Is JWT authentication safe against XSS?

JWTs themselves are not immune to XSS. If an attacker executes JavaScript in your app, they can steal tokens from memory. Use HttpOnly cookies for refresh tokens, implement a strong Content Security Policy, and sanitize all user input.

Do I need HTTPS for JWT authentication?

Absolutely, always in production. Without HTTPS, tokens can be intercepted in transit, making the entire authentication system worthless.

Conclusion

Implementing JWT authentication in Node.js correctly requires more than just signing and verifying tokens. Production-ready systems demand refresh token rotation, secure cookie handling, rate limiting, revocation mechanisms, and defense-in-depth practices. The code patterns shown here are the foundation used in real SaaS platforms and enterprise APIs today.

Start with this baseline, adapt it to your database and framework choices, and always audit your authentication code before shipping. Security is not a feature you add later, it is the foundation everything else rests on.