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

If you’re building a modern API with Express.js, securing your endpoints is non-negotiable. JWT (JSON Web Token) authentication has become the industry standard for stateless authentication in Node.js applications, and for good reason: it’s lightweight, scalable, and works beautifully with single-page apps and mobile clients. In this tutorial, we’ll walk through a production-ready implementation of JWT authentication in Express.js, covering everything from token generation to refresh token rotation. No fluff, just working code with security best practices baked in. What is JWT and Why Use It in Express.js? A JSON Web Token is a compact, URL-safe string composed of three parts: a header, a payload, and a signature. When a user logs in successfully, your server generates a signed token that the client stores and sends back with each subsequent request. The advantages over traditional session-based auth: Stateless: No need to store session data on the server Scalable: Works perfectly across distributed systems and microservices Cross-domain friendly: Ideal for SPAs, mobile apps, and third-party APIs Self-contained: The token carries the user identity and claims Project Setup Let’s start by setting up a fresh Express.js project. Open your terminal and run: mkdir express-jwt-auth cd express-jwt-auth npm init -y npm install express jsonwebtoken bcrypt dotenv cookie-parser npm install –save-dev nodemon Required Dependencies Package Purpose express Web framework jsonwebtoken Sign and verify JWTs bcrypt Hash user passwords securely dotenv Manage environment variables cookie-parser Parse refresh token cookies Creating the .env File Never hardcode secrets. Create a .env file at the root of your project: 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 Pro tip: Generate strong secrets using node -e “console.log(require(‘crypto’).randomBytes(64).toString(‘hex’))”. Step 1: Building the Express Server Create a file called 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 2: Generating Access and Refresh Tokens A robust JWT setup uses two tokens: Access token: Short-lived (15 minutes), sent with every request Refresh token: Long-lived (7 days), used only to get a new access token Create a utils/tokens.js file: const jwt = require(‘jsonwebtoken’); const generateAccessToken = (user) => { return jwt.sign( { id: user.id, email: user.email }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXPIRY } ); }; const generateRefreshToken = (user) => { return jwt.sign( { id: user.id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXPIRY } ); }; module.exports = { generateAccessToken, generateRefreshToken }; Step 3: Building the Auth Routes Create routes/auth.js. For brevity, we’ll use an in-memory user store, but you’d typically use MongoDB, PostgreSQL, or another database. const express = require(‘express’); const bcrypt = require(‘bcrypt’); const jwt = require(‘jsonwebtoken’); const { generateAccessToken, generateRefreshToken } = require(‘../utils/tokens’); const router = express.Router(); const users = []; let refreshTokens = []; // Register router.post(‘/register’, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: ‘Email and password required’ }); } const existing = users.find(u => u.email === email); if (existing) return res.status(409).json({ message: ‘User already exists’ }); const hashedPassword = await bcrypt.hash(password, 12); const user = { id: Date.now().toString(), email, password: hashedPassword }; users.push(user); res.status(201).json({ message: ‘User registered successfully’ }); } catch (err) { res.status(500).json({ message: ‘Server error’ }); } }); // 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.push(refreshToken); res.cookie(‘refreshToken’, refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === ‘production’, sameSite: ‘strict’, maxAge: 7 * 24 * 60 * 60 * 1000 }); res.json({ accessToken }); }); // Refresh router.post(‘/refresh’, (req, res) => { const token = req.cookies.refreshToken; if (!token) return res.status(401).json({ message: ‘No refresh token’ }); if (!refreshTokens.includes(token)) return res.status(403).json({ message: ‘Invalid token’ }); jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => { if (err) return res.status(403).json({ message: ‘Token expired or invalid’ }); const user = users.find(u => u.id === decoded.id); if (!user) return res.status(404).json({ message: ‘User not found’ }); const newAccessToken = generateAccessToken(user); res.json({ accessToken: newAccessToken }); }); }); // Logout router.post(‘/logout’, (req, res) => { const token = req.cookies.refreshToken; refreshTokens = refreshTokens.filter(t => t !== token); res.clearCookie(‘refreshToken’); res.json({ message: ‘Logged out successfully’ }); }); module.exports = router; Step 4: Creating JWT Verification Middleware This middleware will protect any route you attach it to. Create middleware/authenticate.js: const jwt = require(‘jsonwebtoken’); const authenticateToken = (req, res, next) => { const authHeader = req.headers[‘authorization’]; const token = authHeader && authHeader.split(‘ ‘)[1]; if (!token) { return res.status(401).json({ message: ‘Access token required’ }); } jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded) => { if (err) { const message = err.name === ‘TokenExpiredError’ ? ‘Token expired’ : ‘Invalid token’; return res.status(403).json({ message }); } req.user = decoded; next(); }); }; module.exports = authenticateToken; Step 5: Protecting Routes Create routes/protected.js to test our middleware: const express = require(‘express’); const authenticateToken = require(‘../middleware/authenticate’); const router = express.Router(); router.get(‘/profile’, authenticateToken, (req, res) => { res.json({ message: ‘Access granted’, user: req.user }); }); module.exports = router; Testing Your JWT Authentication Flow Start the server with node server.js and test using curl, Postman, or any HTTP client: Register: POST to /api/auth/register with email and password Login: POST to /api/auth/login, receive an access token Access protected route: GET /api/profile with header Authorization: Bearer YOUR_TOKEN Refresh: POST to /api/auth/refresh when the access token expires Logout: POST to /api/auth/logout to invalidate the refresh token Security Best Practices for JWT in Express.js Implementing JWT correctly is more than just generating tokens. Follow these rules in production: Keep access tokens short-lived: 15 minutes is a healthy default Store refresh tokens in HttpOnly cookies: Prevents XSS-based theft Use HTTPS everywhere: Set the secure cookie flag in production Rotate refresh tokens: Issue a new refresh token each time one is used Maintain a token blacklist or database whitelist: For revocation on logout or compromise Use strong secrets: At least 64 random bytes,

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