How to Implement JWT Authentication in a Node.js Application: Complete Tutorial

One of the most popular services we offer is ongoing website maintenance because most clients we work with become return clients.

How to Implement JWT Authentication in a Node.js Application: Complete Tutorial

JWT Authentication in Node.js: A Complete Production-Ready Guide

If you’re building a modern API, JWT authentication in Node.js is probably the most efficient way to handle stateless user sessions. But most tutorials online stop at the basics: a login route, a token, and a quick verify function. In real production apps, that’s not enough. You need refresh tokens, secure storage, token rotation, and proper error handling.

In this tutorial, we’ll build a complete authentication system in Express that you can actually ship. We’ll cover token generation, middleware verification, refresh token rotation, and the security pitfalls most developers miss.

What is JWT and Why Use It in Node.js?

A JSON Web Token (JWT) is a compact, URL-safe token used to transmit claims between two parties. It’s signed (and optionally encrypted) so the receiver can verify its authenticity without storing session data on the server.

A JWT is composed of three parts separated by dots:

  • Header: algorithm and token type
  • Payload: claims (user ID, role, expiration)
  • Signature: cryptographic signature to verify integrity

JWT vs Session-Based Authentication

Feature JWT Sessions
Server storage Stateless Required
Scalability Excellent Requires shared store
Revocation Harder (needs blacklist) Easy
Mobile / SPA friendly Yes Limited

Project Setup

Let’s start by initializing a new Node.js project. We’ll be using Node.js 22 LTS and modern ES modules.

1. Initialize the Project

mkdir jwt-auth-api && cd jwt-auth-api
npm init -y
npm install express jsonwebtoken bcrypt dotenv cookie-parser
npm install -D nodemon

2. Project Structure

jwt-auth-api/
├── src/
│   ├── controllers/
│   │   └── auth.controller.js
│   ├── middleware/
│   │   └── auth.middleware.js
│   ├── routes/
│   │   └── auth.routes.js
│   ├── utils/
│   │   └── token.js
│   └── server.js
├── .env
└── package.json

3. Environment Variables

Create a .env file. Never commit this file to your repository.

PORT=3000
ACCESS_TOKEN_SECRET=your_super_long_random_access_secret_here
REFRESH_TOKEN_SECRET=your_super_long_random_refresh_secret_here
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d

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

Step 1: Token Generation Utility

Create src/utils/token.js. We separate access and refresh token logic so each can be rotated independently.

import jwt from 'jsonwebtoken';

export const generateAccessToken = (user) => {
  return jwt.sign(
    { sub: user.id, role: user.role },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY, algorithm: 'HS256' }
  );
};

export const generateRefreshToken = (user) => {
  return jwt.sign(
    { sub: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY, algorithm: 'HS256' }
  );
};

export const verifyAccessToken = (token) =>
  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);

export const verifyRefreshToken = (token) =>
  jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);

Step 2: Auth Controller (Register, Login, Refresh, Logout)

Create src/controllers/auth.controller.js. For brevity we’ll use an in-memory user store, but in production you should use PostgreSQL, MongoDB, or any persistent database.

import bcrypt from 'bcrypt';
import {
  generateAccessToken,
  generateRefreshToken,
  verifyRefreshToken,
} from '../utils/token.js';

const users = []; // Replace with your DB
const refreshTokens = new Set(); // Replace with Redis in production

export const register = async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) return res.status(400).json({ error: 'Missing fields' });

  const exists = users.find(u => u.email === email);
  if (exists) return res.status(409).json({ error: 'User already exists' });

  const hashed = await bcrypt.hash(password, 12);
  const user = { id: crypto.randomUUID(), email, password: hashed, role: 'user' };
  users.push(user);

  res.status(201).json({ message: 'User created' });
};

export const login = async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

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

  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
  refreshTokens.add(refreshToken);

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

  res.json({ accessToken });
};

export const refresh = (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token || !refreshTokens.has(token)) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  try {
    const payload = verifyRefreshToken(token);
    const user = users.find(u => u.id === payload.sub);
    if (!user) return res.status(401).json({ error: 'User not found' });

    // Rotate refresh token
    refreshTokens.delete(token);
    const newRefresh = generateRefreshToken(user);
    refreshTokens.add(newRefresh);

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

    const accessToken = generateAccessToken(user);
    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: 'Expired or invalid refresh token' });
  }
};

export const logout = (req, res) => {
  const token = req.cookies.refreshToken;
  refreshTokens.delete(token);
  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out' });
};

Step 3: Authentication Middleware

Create src/middleware/auth.middleware.js. This middleware will protect any route that requires a valid access token.

import { verifyAccessToken } from '../utils/token.js';

export const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed token' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = verifyAccessToken(token);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
};

export const authorize = (...roles) => (req, res, next) => {
  if (!roles.includes(req.user.role)) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
};

Step 4: Routes and Server

Create src/routes/auth.routes.js:

import { Router } from 'express';
import { register, login, refresh, logout } from '../controllers/auth.controller.js';
import { authenticate, authorize } from '../middleware/auth.middleware.js';

const router = Router();

router.post('/register', register);
router.post('/login', login);
router.post('/refresh', refresh);
router.post('/logout', logout);

router.get('/me', authenticate, (req, res) => res.json({ user: req.user }));
router.get('/admin', authenticate, authorize('admin'), (req, res) =>
  res.json({ secret: 'admin only data' })
);

export default router;

And src/server.js:

import express from 'express';
import cookieParser from 'cookie-parser';
import 'dotenv/config';
import authRoutes from './routes/auth.routes.js';

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRoutes);

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

Testing the Authentication Flow

  1. Register: POST /api/auth/register with email and password
  2. Login: POST /api/auth/login returns an access token and sets a refresh cookie
  3. Access protected route: GET /api/auth/me with header Authorization: Bearer <token>
  4. Refresh: POST /api/auth/refresh when access token expires
  5. Logout: POST /api/auth/logout invalidates the refresh token

Security Best Practices for Production

This is where most tutorials fall short. Below are the practices we apply at Pixelseed when shipping authentication systems.

Token Storage

  • Access tokens: keep in memory on the client (never localStorage if you can avoid it). Short lifetime (5 to 15 minutes).
  • Refresh tokens: store in httpOnly, Secure, SameSite=Strict cookies. Never expose them to JavaScript.

Refresh Token Rotation

Every time a refresh token is used, issue a new one and invalidate the previous. If a stolen token is reused, you can detect the breach and revoke the entire family of tokens.

Use Strong Algorithms

  • Use HS256 for symmetric setups, or RS256 / EdDSA for distributed services where consumers verify but only the auth server signs.
  • Never accept alg: none. Always pin algorithms in jwt.verify.

Add Rate Limiting and Brute Force Protection

npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: 'Too many login attempts',
});

router.post('/login', loginLimiter, login);

Other Hardening Tips

  • Use HTTPS everywhere in production
  • Use helmet for secure HTTP headers
  • Store the refresh token allowlist in Redis, not in memory
  • Keep payloads small. Never put sensitive data in the JWT (it’s only base64 encoded, not encrypted)
  • Set up monitoring on failed verifications to detect attacks

Common Mistakes to Avoid

  • Long-lived access tokens: if compromised, the attacker has hours of access
  • Storing tokens in localStorage: vulnerable to XSS
  • Not validating the algorithm: opens the door to algorithm confusion attacks
  • Putting passwords or PII in the payload: anyone can decode it
  • Forgetting to invalidate tokens on logout: refresh tokens stay valid

FAQ

What is the best JWT library for Node.js?

The most widely used library is jsonwebtoken. For more advanced use cases (JWE, JWKS, key rotation), jose is a modern alternative with native support for the Web Crypto API.

Should I use JWT or sessions?

Use JWT for stateless APIs, mobile apps, and microservices. Use sessions for traditional server-rendered apps where revocation and simplicity matter more than scalability.

How long should an access token last?

Between 5 and 15 minutes is a sweet spot. Combined with a refresh token of 7 to 30 days, you get a good balance between security and user experience.

What’s the difference between JWT and OAuth?

OAuth 2.0 is an authorization framework. JWT is a token format. OAuth often uses JWTs as access tokens, but you can implement JWT authentication without OAuth.

Can JWTs be revoked?

Not natively, since they are stateless. The common approach is to keep a short access token lifetime and maintain a server-side allowlist (or denylist) of refresh tokens, typically in Redis.

Conclusion

You now have a complete, production-grade JWT authentication system in Node.js with access tokens, rotating refresh tokens, role-based authorization, and the security practices needed to ship to real users. The next step is to plug it into a real database, add email verification, and integrate two-factor authentication.

At Pixelseed, we build secure backends and authentication systems for SaaS and enterprise clients. If you need help architecting auth for your product, get in touch.

Subscription Form

Contact Details

Quick Links

Copyright © 2022 Pixel Seed. All Rights Reserved.