How to Implement JWT Authentication in Express.js: A Complete Tutorial

If you’re building a modern Node.js API, chances are you need a reliable way to authenticate users. JWT authentication in Express remains one of the most popular and scalable approaches in 2026, especially for stateless APIs, microservices, and mobile backends. In this tutorial, we’ll walk through building a complete JWT auth flow from scratch: user registration, login, token generation, middleware protection, and a proper refresh token strategy. Every snippet is copy-paste ready so you can adapt it to your own project. What Is JWT Authentication and Why Use It in Express? A JSON Web Token (JWT) is a compact, URL-safe token containing signed claims about a user. Once issued after login, the client sends it in the Authorization header on every request, and your Express server verifies its signature without touching the database. Key benefits: Stateless: no session storage needed on the server Scalable: works perfectly across multiple servers and microservices Portable: the same token can secure REST APIs, GraphQL, and even WebSockets Cross-domain friendly: ideal for SPAs and mobile apps Prerequisites Node.js 20+ installed Basic Express.js knowledge A code editor (VS Code recommended) Step 1: Project Setup Create a new folder and initialize your project: mkdir express-jwt-auth && cd express-jwt-auth npm init -y npm install express jsonwebtoken bcryptjs dotenv cookie-parser npm install –save-dev nodemon Here’s what each package does: Package Purpose express Web framework jsonwebtoken Sign and verify JWTs bcryptjs Hash user passwords dotenv Load secrets from .env cookie-parser Parse HTTP cookies for refresh tokens Configure environment variables Create a .env file at the project root: PORT=3000 ACCESS_TOKEN_SECRET=your_super_long_random_access_secret_here REFRESH_TOKEN_SECRET=your_super_long_random_refresh_secret_here ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=7d Pro tip: generate strong secrets with node -e “console.log(require(‘crypto’).randomBytes(64).toString(‘hex’))”. Step 2: Building the Express Server Create server.js: require(‘dotenv’).config(); const express = require(‘express’); const cookieParser = require(‘cookie-parser’); const authRoutes = require(‘./routes/auth’); const protectedRoutes = require(‘./routes/protected’); const app = express(); app.use(express.json()); app.use(cookieParser()); app.use(‘/api/auth’, authRoutes); app.use(‘/api’, protectedRoutes); const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); Step 3: Creating a Simple User Store For clarity, we’ll use an in-memory array. In production, replace this with your database (PostgreSQL, MongoDB, etc.). Create data/users.js: const users = []; const refreshTokens = new Set(); module.exports = { users, refreshTokens }; Step 4: Token Generation Helpers Create utils/tokens.js: const jwt = require(‘jsonwebtoken’); function generateAccessToken(user) { return jwt.sign( { sub: user.id, email: user.email, role: user.role }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: process.env.ACCESS_TOKEN_TTL } ); } function generateRefreshToken(user) { return jwt.sign( { sub: user.id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.REFRESH_TOKEN_TTL } ); } module.exports = { generateAccessToken, generateRefreshToken }; Notice that we use two separate secrets for access and refresh tokens. This is critical: if one is leaked, the other still holds. Step 5: Register and Login Routes Create routes/auth.js: const express = require(‘express’); const bcrypt = require(‘bcryptjs’); const jwt = require(‘jsonwebtoken’); const { users, refreshTokens } = require(‘../data/users’); const { generateAccessToken, generateRefreshToken } = require(‘../utils/tokens’); const router = express.Router(); // REGISTER router.post(‘/register’, async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ message: ‘Missing fields’ }); const exists = users.find(u => u.email === email); if (exists) return res.status(409).json({ message: ‘User already exists’ }); const hashed = await bcrypt.hash(password, 12); const user = { id: Date.now().toString(), email, password: hashed, role: ‘user’ }; users.push(user); res.status(201).json({ message: ‘User registered’ }); }); // LOGIN router.post(‘/login’, async (req, res) => { const { email, password } = req.body; const user = users.find(u => u.email === email); if (!user) return res.status(401).json({ message: ‘Invalid credentials’ }); const valid = await bcrypt.compare(password, user.password); if (!valid) return res.status(401).json({ message: ‘Invalid credentials’ }); const accessToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user); refreshTokens.add(refreshToken); res.cookie(‘refreshToken’, refreshToken, { httpOnly: true, secure: true, sameSite: ‘strict’, maxAge: 7 * 24 * 60 * 60 * 1000 }); res.json({ accessToken }); }); // REFRESH router.post(‘/refresh’, (req, res) => { const token = req.cookies.refreshToken; if (!token || !refreshTokens.has(token)) { return res.status(401).json({ message: ‘Refresh token invalid’ }); } jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, payload) => { if (err) return res.status(403).json({ message: ‘Token expired’ }); const user = users.find(u => u.id === payload.sub); if (!user) return res.status(404).json({ message: ‘User not found’ }); const accessToken = generateAccessToken(user); res.json({ accessToken }); }); }); // LOGOUT router.post(‘/logout’, (req, res) => { const token = req.cookies.refreshToken; refreshTokens.delete(token); res.clearCookie(‘refreshToken’); res.json({ message: ‘Logged out’ }); }); module.exports = router; Step 6: The Authentication Middleware The middleware is where JWT authentication in Express really shines. Create middleware/authenticate.js: const jwt = require(‘jsonwebtoken’); function authenticate(req, res, next) { const authHeader = req.headers[‘authorization’]; const token = authHeader && authHeader.split(‘ ‘)[1]; if (!token) return res.status(401).json({ message: ‘Access token missing’ }); jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, payload) => { if (err) return res.status(403).json({ message: ‘Invalid or expired token’ }); req.user = payload; next(); }); } module.exports = authenticate; Optional: Role-Based Authorization function authorize(…roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ message: ‘Forbidden’ }); } next(); }; } module.exports.authorize = authorize; Step 7: Protecting Routes Create routes/protected.js: const express = require(‘express’); const authenticate = require(‘../middleware/authenticate’); const router = express.Router(); router.get(‘/profile’, authenticate, (req, res) => { res.json({ message: ‘You accessed a protected route’, user: req.user }); }); module.exports = router; Step 8: Testing the Full Flow Start the server: npx nodemon server.js Register a user via POST to /api/auth/register Login via POST to /api/auth/login and copy the accessToken Call /api/profile with the header Authorization: Bearer <accessToken> After 15 minutes, hit /api/auth/refresh to get a new access token (the refresh cookie is sent automatically) Understanding the Refresh Token Strategy The most common mistake developers make is issuing long-lived access tokens. A safer pattern: Token Lifetime Storage Access Token 15 minutes Memory / Authorization header Refresh Token 7 days HttpOnly, Secure cookie For extra security, implement refresh token rotation: every time /refresh is called, issue a new refresh token and invalidate the previous one. If an old token is ever reused, revoke the entire session (this indicates possible theft). Security Best Practices for 2026 Always use HTTPS in production, no exceptions Store refresh tokens in HttpOnly, Secure, SameSite=strict cookies Never store JWTs in localStorage if you can avoid it

How to Implement JWT Authentication in Express.js: A Complete Tutorial Read More »