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/loginand copy theaccessToken - Call
/api/profilewith the headerAuthorization: Bearer <accessToken> - After 15 minutes, hit
/api/auth/refreshto 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
localStorageif you can avoid it (XSS risk) - Keep access token TTL short (5-15 minutes)
- Use asymmetric signing (RS256) if multiple services verify tokens
- Persist refresh tokens in a database (Redis is ideal) so you can revoke them
- Add rate limiting with
express-rate-limiton login and refresh endpoints - Include
jti(JWT ID) claims for auditability - Validate the
Content-Typeheader and sanitize all inputs
Common Pitfalls to Avoid
- Hardcoding secrets in your source code, use environment variables
- Not verifying token expiration, always let
jsonwebtokendo it - Reusing the same secret for access and refresh tokens
- Trusting the payload blindly, still validate the user exists on sensitive actions
- Skipping CORS configuration when using cookies across origins
FAQ
Should I use sessions or JWT for Express authentication?
Use JWT when you need stateless authentication across multiple servers, mobile clients, or microservices. Stick with sessions when your app is a monolith with server-rendered pages, since they’re simpler and easier to revoke.
Where should I store JWT tokens on the client?
Store the short-lived access token in memory (e.g., a React state or a JS variable) and the refresh token in an HttpOnly cookie. This combination minimizes both XSS and CSRF risks.
How do I revoke a JWT?
JWTs are stateless by design and can’t be revoked individually unless you maintain a blocklist or store refresh tokens in a database (like Redis). When a user logs out or a token is compromised, delete the refresh token record and let the short-lived access token expire naturally.
What algorithm should I use to sign JWTs?
HS256 (symmetric) is fine for a single backend. If you have multiple services verifying tokens, use RS256 (asymmetric) so services only need the public key.
Do I need the express-jwt package?
Not necessarily. The jsonwebtoken package with a custom middleware (as shown above) gives you full control. Use express-jwt if you prefer a plug-and-play middleware maintained by Auth0.
Wrapping Up
You now have a production-grade blueprint for JWT authentication in Express.js: secure password hashing, short-lived access tokens, refresh tokens stored in HttpOnly cookies, protected routes, and role-based authorization. Copy the snippets, wire them to your real database, add rate limiting, and you’re ready to ship.
Need help integrating this into an existing app or scaling authentication across microservices? The Pixelating Bits team can help. Reach out and let’s build something secure together.