JWT Authentication in Node.js & Express

JWT Authentication in Node.js & Express
Authentication is the front door of your API. Get it wrong and every other security measure you build is irrelevant. JWT (JSON Web Token) authentication is the dominant stateless auth pattern for REST APIs — no sessions, no shared database lookups, just a cryptographically signed token that proves who the caller is.
This module builds a complete JWT auth system: registration, login, protected routes, refresh tokens, and logout — all production-ready.
This is Module 18 of the Node.js Full‑Stack Developer course.
How JWT Authentication Works
1. User logs in with email + password
2. Server verifies credentials → signs a JWT with userId + role
3. Server returns: access token (short-lived) + refresh token (httpOnly cookie)
4. Client sends access token in Authorization header on every request
5. Server verifies signature → extracts userId → serves the request
6. When access token expires, client calls /auth/refresh with the cookie
7. Server issues a new access tokenNo session table. No database lookup per request. Just a signature verification.
Installing jsonwebtoken
npm install jsonwebtoken
npm install --save-dev @types/jsonwebtoken # if using TypeScriptToken Utilities
// lib/tokens.js
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const ACCESS_TTL = process.env.JWT_ACCESS_TTL ?? '15m';
const REFRESH_TTL = process.env.JWT_REFRESH_TTL ?? '7d';
if (!ACCESS_SECRET || !REFRESH_SECRET) {
throw new Error('JWT secrets must be set in environment variables');
}
export function signAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: ACCESS_TTL,
issuer: 'myapp',
});
}
export function signRefreshToken(payload) {
return jwt.sign(payload, REFRESH_SECRET, {
expiresIn: REFRESH_TTL,
issuer: 'myapp',
});
}
export function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_SECRET, { issuer: 'myapp' });
}
export function verifyRefreshToken(token) {
return jwt.verify(token, REFRESH_SECRET, { issuer: 'myapp' });
}# .env
JWT_ACCESS_SECRET=your-super-secret-access-key-min-32-chars
JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-chars
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=7dGenerate secure secrets:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"The Auth Feature
User Model
// features/users/users.model.js
import mongoose from 'mongoose';
import bcrypt from 'bcrypt';
const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
password: { type: String, required: true, minlength: 8, select: false },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
tokenVersion: { type: Number, default: 0 }, // for token invalidation
},
{ timestamps: true }
);
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = function (candidate) {
return bcrypt.compare(candidate, this.password);
};
export const User = mongoose.model('User', userSchema);Auth Service
// features/auth/auth.service.js
import { User } from '../users/users.model.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/tokens.js';
import { AppError } from '../../lib/errors.js';
function buildTokenPayload(user) {
return {
sub: user._id.toString(),
role: user.role,
version: user.tokenVersion,
};
}
export async function register({ name, email, password }) {
const exists = await User.findOne({ email });
if (exists) throw new AppError('Email already registered', 409);
const user = await User.create({ name, email, password });
const payload = buildTokenPayload(user);
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: { id: user._id, name: user.name, email: user.email, role: user.role },
};
}
export async function login({ email, password }) {
const user = await User.findOne({ email }).select('+password');
if (!user) throw new AppError('Invalid email or password', 401);
const valid = await user.comparePassword(password);
if (!valid) throw new AppError('Invalid email or password', 401);
const payload = buildTokenPayload(user);
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
user: { id: user._id, name: user.name, email: user.email, role: user.role },
};
}
export async function refresh(refreshToken) {
if (!refreshToken) throw new AppError('Refresh token missing', 401);
let decoded;
try {
decoded = verifyRefreshToken(refreshToken);
} catch {
throw new AppError('Invalid or expired refresh token', 401);
}
// Check tokenVersion to support logout-all
const user = await User.findById(decoded.sub);
if (!user || user.tokenVersion !== decoded.version) {
throw new AppError('Session expired, please log in again', 401);
}
const payload = buildTokenPayload(user);
return { accessToken: signAccessToken(payload) };
}
export async function logout(userId) {
// Increment tokenVersion — invalidates all refresh tokens for this user
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
}Auth Controller
// features/auth/auth.controller.js
import * as authService from './auth.service.js';
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
path: '/api/v1/auth/refresh', // scope cookie to refresh endpoint only
};
export async function register(req, res, next) {
try {
const { accessToken, refreshToken, user } = await authService.register(req.body);
res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);
res.status(201).json({ accessToken, user });
} catch (err) {
next(err);
}
}
export async function login(req, res, next) {
try {
const { accessToken, refreshToken, user } = await authService.login(req.body);
res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);
res.json({ accessToken, user });
} catch (err) {
next(err);
}
}
export async function refresh(req, res, next) {
try {
const refreshToken = req.cookies?.refreshToken;
const { accessToken } = await authService.refresh(refreshToken);
res.json({ accessToken });
} catch (err) {
next(err);
}
}
export async function logout(req, res, next) {
try {
await authService.logout(req.user.sub);
res.clearCookie('refreshToken', { path: '/api/v1/auth/refresh' });
res.status(204).send();
} catch (err) {
next(err);
}
}
export async function me(req, res) {
// req.user is populated by the auth middleware
res.json(req.user);
}Auth Router
// features/auth/auth.router.js
import express from 'express';
import * as controller from './auth.controller.js';
import { validate } from '../../middleware/validate.js';
import { requireAuth } from '../../middleware/auth.js';
import { registerSchema, loginSchema } from './auth.schema.js';
const router = express.Router();
router.post('/register', validate(registerSchema), controller.register);
router.post('/login', validate(loginSchema), controller.login);
router.post('/refresh', controller.refresh);
router.post('/logout', requireAuth, controller.logout);
router.get('/me', requireAuth, controller.me);
export default router;Validation Schemas
// features/auth/auth.schema.js
import Joi from 'joi';
export const registerSchema = Joi.object({
name: Joi.string().min(2).max(100).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
});
export const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required(),
});The Auth Middleware
This is the guard that sits in front of every protected route:
// middleware/auth.js
import { verifyAccessToken } from '../lib/tokens.js';
import { AppError } from '../lib/errors.js';
export function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new AppError('Authentication required', 401));
}
const token = authHeader.slice(7); // remove "Bearer "
try {
const decoded = verifyAccessToken(token);
req.user = decoded; // { sub, role, version, iat, exp }
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return next(new AppError('Access token expired', 401));
}
return next(new AppError('Invalid access token', 401));
}
}Usage on any router:
// Protected routes
router.get('/profile', requireAuth, controller.getProfile);
router.put('/profile', requireAuth, validate(updateSchema), controller.updateProfile);
// Apply to all routes in a router
router.use(requireAuth);Cookie Parser Setup
The refresh token lives in an httpOnly cookie. Parse it in app.js:
npm install cookie-parser// app.js
import cookieParser from 'cookie-parser';
app.use(cookieParser());Complete Auth Flow in Practice
1. Register
POST /api/v1/auth/register
Body: { "name": "Alice", "email": "alice@example.com", "password": "secret123" }
Response 201:
{
"accessToken": "eyJhbGci...",
"user": { "id": "...", "name": "Alice", "email": "alice@example.com", "role": "user" }
}
Set-Cookie: refreshToken=eyJhbGci...; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth/refresh2. Call a Protected Route
GET /api/v1/users/me
Authorization: Bearer eyJhbGci...
Response 200:
{ "sub": "...", "role": "user", "version": 0, "iat": ..., "exp": ... }3. Refresh the Access Token
POST /api/v1/auth/refresh
Cookie: refreshToken=eyJhbGci... (sent automatically by browser)
Response 200:
{ "accessToken": "eyJhbGci...newtoken..." }4. Logout
POST /api/v1/auth/logout
Authorization: Bearer eyJhbGci...
Response 204 (no body)
Set-Cookie: refreshToken=; expires=Thu, 01 Jan 1970 ... (cleared)Token Verification in Error Handler
Add JWT error handling to the global error handler:
// middleware/errorHandler.js
import jwt from 'jsonwebtoken';
export function errorHandler(err, req, res, next) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ status: 'error', message: 'Invalid token' });
}
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ status: 'error', message: 'Token expired' });
}
const status = err.statusCode || 500;
res.status(status).json({ status: 'error', message: err.message });
}Security Checklist
| Practice | Why |
|---|---|
| Use separate secrets for access and refresh tokens | Compromise of one does not expose the other |
| Keep access token TTL short (15 minutes) | Limits window of stolen token abuse |
Store refresh token in httpOnly cookie | JavaScript (XSS) cannot read it |
Scope cookie path to the refresh endpoint | Refresh token is not sent with every API call |
Use sameSite: 'strict' | Prevents CSRF attacks from using the cookie |
| Never log or return the raw token | Tokens in logs are a security incident |
| Always use HTTPS in production | Tokens in plaintext are trivially stolen |
Embed tokenVersion in payload | Enables instant logout-all |
Generate secrets with crypto.randomBytes(64) | Brute-force resistant |
Set issuer and verify it | Prevents token substitution attacks |
Node.js Full‑Stack Course — Module 18 of 32
Your API now has complete JWT authentication with refresh tokens. Continue to Module 19 to learn how to hash passwords securely with bcrypt.
Summary
JWT authentication requires getting several details right simultaneously:
- Sign access tokens with a short TTL (15m); sign refresh tokens with a long TTL (7d) using a separate secret
- Return the access token in the response body and the refresh token in an
httpOnly,Secure,SameSite=Strictcookie scoped to the refresh path - The
requireAuthmiddleware extracts the Bearer token, verifies the signature, and attachesreq.user— keep it simple and focused - Embed
tokenVersionin the payload and increment it on logout to invalidate all outstanding refresh tokens - Parse cookies with
cookie-parserbefore the router that handles/auth/refresh - Handle
TokenExpiredErrorandJsonWebTokenErrorexplicitly — never let them surface as 500 errors - Never store sensitive data in the payload — it is encoded, not encrypted
Continue to Module 19: Password Hashing with bcrypt →
