Most tutorials on JWT authentication in Express.js stop at “sign a token, send it back, verify it.” That’s fine for a demo, but it leaves your API wide open in production. Missing refresh tokens, tokens stored in localStorage, no rotation, weak secrets, and no revocation strategy are some of the most common security gaps we see during code audits at Santiance.
This guide walks you through a complete, production-ready JWT authentication flow in Express.js. We will cover access tokens, refresh tokens with rotation, middleware, secure cookie storage, and how to revoke compromised sessions.
What You Will Build
By the end of this tutorial, you will have a working Express.js API with:
- User registration and login with hashed passwords (bcrypt)
- Short-lived access tokens (15 minutes)
- Long-lived refresh tokens (7 days) with rotation
- An authentication middleware to protect routes
- Secure HTTP-only cookie storage
- A logout endpoint that revokes refresh tokens

Why JWT (and When Not to Use It)
JWT is popular because it is stateless: the server does not need to query a session store for every request. But stateless also means harder to revoke. Here is a quick decision table:
| Use Case | Recommended |
|---|---|
| Public REST/GraphQL API consumed by SPAs or mobile apps | JWT |
| Microservices needing to forward identity | JWT |
| Classic server-rendered website | Session cookies |
| Third-party API access delegation | OAuth 2.0 / OIDC |
Step 1: Project Setup
Initialize a new Node.js project and install the required dependencies:
mkdir jwt-auth-api && cd jwt-auth-api
npm init -y
npm install express jsonwebtoken bcrypt cookie-parser dotenv
npm install --save-dev nodemon
Create a .env file. Never commit this file.
PORT=4000
ACCESS_TOKEN_SECRET=replace_with_64_byte_random_string
REFRESH_TOKEN_SECRET=replace_with_another_64_byte_random_string
NODE_ENV=development
Generate strong secrets with:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Step 2: User Model and Password Hashing
For brevity we will use an in-memory store. In production swap this for PostgreSQL, MongoDB, or your database of choice.
// users.js
const bcrypt = require('bcrypt');
const users = new Map(); // email -> { id, email, passwordHash, refreshTokens: Set }
async function createUser(email, password) {
if (users.has(email)) throw new Error('User exists');
const passwordHash = await bcrypt.hash(password, 12);
const user = { id: crypto.randomUUID(), email, passwordHash, refreshTokens: new Set() };
users.set(email, user);
return user;
}
async function verifyUser(email, password) {
const user = users.get(email);
if (!user) return null;
const ok = await bcrypt.compare(password, user.passwordHash);
return ok ? user : null;
}
module.exports = { users, createUser, verifyUser };
Important security notes
- Use a bcrypt cost factor of at least 12.
- Always store the hash, never the plaintext password.
- Store refresh tokens (or their hashes) so you can revoke them.

Step 3: Token Generation Helpers
// tokens.js
const jwt = require('jsonwebtoken');
function generateAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m', issuer: 'santiance-api', audience: 'santiance-client' }
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ sub: user.id, jti: crypto.randomUUID() },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d', issuer: 'santiance-api', audience: 'santiance-client' }
);
}
module.exports = { generateAccessToken, generateRefreshToken };
Key choices explained:
- Separate secrets for access and refresh tokens limit damage if one leaks.
- Short access token lifetime (15 min) reduces the window of misuse.
- A jti claim on refresh tokens uniquely identifies each one, enabling revocation.
- issuer and audience claims block tokens minted by other systems.
Step 4: Authentication Middleware
// authMiddleware.js
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
issuer: 'santiance-api',
audience: 'santiance-client'
});
req.user = { id: payload.sub, email: payload.email };
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = { authenticate };
Step 5: Register, Login, Refresh, and Logout Routes
// server.js
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { createUser, verifyUser, users } = require('./users');
const { generateAccessToken, generateRefreshToken } = require('./tokens');
const { authenticate } = require('./authMiddleware');
const app = express();
app.use(express.json());
app.use(cookieParser());
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000
};
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 10) {
return res.status(400).json({ error: 'Invalid input' });
}
try {
const user = await createUser(email, password);
res.status(201).json({ id: user.id, email: user.email });
} catch (e) {
res.status(409).json({ error: e.message });
}
});
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
user.refreshTokens.add(refreshToken);
res.cookie('refreshToken', refreshToken, cookieOptions);
res.json({ accessToken });
});
app.post('/auth/refresh', (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: 'No refresh token' });
let payload;
try {
payload = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, {
issuer: 'santiance-api',
audience: 'santiance-client'
});
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const user = [...users.values()].find(u => u.id === payload.sub);
if (!user || !user.refreshTokens.has(token)) {
// Token reuse detected: revoke everything
if (user) user.refreshTokens.clear();
return res.status(401).json({ error: 'Refresh token revoked' });
}
// Rotate: invalidate old, issue new
user.refreshTokens.delete(token);
const newRefresh = generateRefreshToken(user);
user.refreshTokens.add(newRefresh);
const newAccess = generateAccessToken(user);
res.cookie('refreshToken', newRefresh, cookieOptions);
res.json({ accessToken: newAccess });
});
app.post('/auth/logout', (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
try {
const payload = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
const user = [...users.values()].find(u => u.id === payload.sub);
if (user) user.refreshTokens.delete(token);
} catch {}
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.status(204).end();
});
app.get('/me', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.listen(process.env.PORT, () => console.log('API ready'));

Step 6: Secure Storage Strategy
Where you store tokens on the client matters as much as how you sign them.
| Storage | XSS Risk | CSRF Risk | Verdict |
|---|---|---|---|
| localStorage | High | Low | Avoid |
| HttpOnly cookie | Low | Medium (mitigated by SameSite) | Recommended for refresh token |
| In-memory (JS variable) | Low | None | Recommended for access token |
The pattern we just implemented uses refresh token in an HttpOnly cookie and access token in memory, which gives you the best balance of security and UX.
Step 7: Production Hardening Checklist
- Force HTTPS everywhere (secure: true on cookies).
- Add helmet for security headers and express-rate-limit on /auth/login.
- Consider switching to RS256 (asymmetric keys) if multiple services verify tokens.
- Log refresh token rotation events to detect anomalies.
- Implement an account lockout after repeated failed logins.
- Rotate your signing secrets periodically using a key ID (kid) header.
- Keep payloads small: never put sensitive data in a JWT, it is only signed, not encrypted.
Common Mistakes to Avoid
- Using the same secret for access and refresh tokens.
- Setting algorithm: ‘none’ or accepting it on verify.
- Storing JWTs in localStorage on apps that render third-party content.
- No refresh token rotation, allowing infinite reuse.
- Encoding roles or permissions that change frequently inside the token without a way to invalidate it.
FAQ
Should I use JWT or OAuth 2.0?
They solve different problems. JWT is a token format. OAuth 2.0 is an authorization framework that often uses JWTs as access tokens. If you are building first-party auth for your own SPA or mobile app, JWT alone is fine. If you need third parties to access user data on a user’s behalf, use OAuth 2.0 / OIDC.
How long should an access token live?
Between 5 and 30 minutes. Short enough to limit the impact of a leak, long enough to avoid hammering your refresh endpoint.
Can I revoke a JWT before it expires?
Not natively, that is the trade-off of statelessness. The common pattern is to maintain a server-side allowlist of valid refresh token IDs (jti) and to keep access token lifetimes short so revocation effectively happens at the next refresh.
Should I use jsonwebtoken or express-jwt?
jsonwebtoken gives you full control, which we recommend in this tutorial. express-jwt is a thin middleware wrapper that is useful when you only need verification on protected routes and want less boilerplate.
What about TypeScript?
The same pattern applies. Type your JWT payload with an interface and use jwt.verify with a generic so req.user is fully typed in your middleware.
Wrapping Up
You now have a JWT authentication system in Express.js that goes well beyond the typical tutorial: separate secrets, short-lived access tokens, rotating refresh tokens stored in HttpOnly cookies, automatic reuse detection, and a clean logout flow. This is the baseline we use at Santiance when reviewing Node.js APIs for clients, and it should serve as a solid foundation for your next project.
If you want us to audit your authentication layer or help you migrate from session-based auth, get in touch with our team.
