Why JWT Authentication in Node.js Still Dominates in 2026
If you are building APIs or web applications with Node.js, chances are you need a reliable way to authenticate users. JWT authentication in Node.js remains one of the most widely adopted approaches because it is stateless, scalable, and straightforward to implement.
JSON Web Tokens (JWT) let your server verify a user’s identity without storing session data in memory or a database. The token itself carries all the information needed, signed cryptographically so it cannot be tampered with.
In this guide, we will walk through every step required to build a production-ready JWT authentication system in Node.js. You will get annotated code examples you can drop into a real project, along with explanations of why each decision matters.
What You Will Learn
- How JWTs work under the hood
- Setting up a Node.js project with Express
- User registration and password hashing
- Generating access tokens and refresh tokens
- Protecting routes with authentication middleware
- Implementing a token refresh flow
- Common security pitfalls and how to avoid them
Prerequisites
Before we start coding, make sure you have the following ready:
- Node.js 20+ installed (LTS recommended)
- A package manager: npm or pnpm
- Basic knowledge of Express.js
- A MongoDB instance (local or MongoDB Atlas) or any database of your choice
- A code editor such as VS Code
How JWT Authentication Works: A Quick Overview
Before touching any code, let’s understand the flow at a high level.
- The user sends their credentials (email and password) to the server.
- The server verifies the credentials against the database.
- If valid, the server creates a JWT access token (short-lived) and a refresh token (long-lived) and sends both to the client.
- The client stores the tokens and includes the access token in the
Authorizationheader of every subsequent request. - A middleware on the server verifies the token before granting access to protected resources.
- When the access token expires, the client uses the refresh token to obtain a new one without forcing the user to log in again.
Anatomy of a JWT
A JSON Web Token is composed of three Base64-encoded parts separated by dots:
| Part | Content | Example Fields |
|---|---|---|
| Header | Algorithm and token type | alg: HS256, typ: JWT |
| Payload | Claims (user data, expiration, etc.) | sub, email, iat, exp |
| Signature | HMAC or RSA signature of header + payload | Used to verify integrity |
Step 1: Initialize the Project
Create a new directory and initialize a Node.js project:
mkdir jwt-auth-demo
cd jwt-auth-demo
npm init -y
Install the packages we need:
npm install express jsonwebtoken bcryptjs dotenv mongoose cookie-parser
| Package | Purpose |
|---|---|
| express | Web framework |
| jsonwebtoken | Create and verify JWTs |
| bcryptjs | Hash passwords |
| dotenv | Load environment variables |
| mongoose | MongoDB ODM |
| cookie-parser | Parse cookies (for refresh tokens) |
Step 2: Project Structure
Keep things organized from the start. Here is a simple structure that scales well:
jwt-auth-demo/
├── controllers/
│ └── authController.js
├── middleware/
│ └── authMiddleware.js
├── models/
│ └── User.js
├── routes/
│ └── authRoutes.js
├── utils/
│ └── tokenUtils.js
├── .env
├── server.js
└── package.json
Step 3: Configure Environment Variables
Create a .env file in the project root. Never commit this file to version control.
PORT=4000
MONGO_URI=mongodb://localhost:27017/jwt-auth-demo
ACCESS_TOKEN_SECRET=your_access_token_secret_here_replace_me
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here_replace_me
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
Tip: Generate strong secrets with node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Step 4: Create the User Model
File: models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
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 },
refreshTokens: [String] // store hashed refresh tokens
}, { timestamps: true });
// Hash password before saving
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Compare candidate password with stored hash
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Notice we store an array of hashed refresh tokens on the user document. This allows us to invalidate specific tokens later, which is critical for logout and token rotation.
Step 5: Token Utility Functions
File: utils/tokenUtils.js
const jwt = require('jsonwebtoken');
/**
* Generate an access token (short-lived)
*/
function generateAccessToken(user) {
return jwt.sign(
{ userId: user._id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRY } // e.g. '15m'
);
}
/**
* Generate a refresh token (long-lived)
*/
function generateRefreshToken(user) {
return jwt.sign(
{ userId: user._id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRY } // e.g. '7d'
);
}
/**
* Verify an access token
*/
function verifyAccessToken(token) {
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
}
/**
* Verify a refresh token
*/
function verifyRefreshToken(token) {
return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
}
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken
};
Step 6: Authentication Controller
This is where the core logic lives: registration, login, token refresh, and logout.
File: controllers/authController.js
6a. Register
const User = require('../models/User');
const bcrypt = require('bcryptjs');
const {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken
} = require('../utils/tokenUtils');
exports.register = async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({ message: 'Email already registered' });
}
// Create user (password hashing happens in the pre-save hook)
const user = await User.create({ name, email, password });
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store hashed refresh token
const hashedRT = await bcrypt.hash(refreshToken, 10);
user.refreshTokens.push(hashedRT);
await user.save({ validateBeforeSave: false });
// Send refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // set to true in production (HTTPS)
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
return res.status(201).json({ accessToken, user: { id: user._id, name, email } });
} catch (err) {
return res.status(500).json({ message: 'Server error', error: err.message });
}
};
6b. Login
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ message: 'Invalid email or password' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid email or password' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store hashed refresh token
const hashedRT = await bcrypt.hash(refreshToken, 10);
user.refreshTokens.push(hashedRT);
await user.save({ validateBeforeSave: false });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
return res.json({ accessToken, user: { id: user._id, name: user.name, email } });
} catch (err) {
return res.status(500).json({ message: 'Server error', error: err.message });
}
};
6c. Refresh Token
exports.refresh = async (req, res) => {
try {
const token = req.cookies.refreshToken;
if (!token) {
return res.status(401).json({ message: 'Refresh token missing' });
}
// Verify the refresh token signature
let payload;
try {
payload = verifyRefreshToken(token);
} catch (err) {
return res.status(403).json({ message: 'Invalid or expired refresh token' });
}
const user = await User.findById(payload.userId);
if (!user) {
return res.status(403).json({ message: 'User not found' });
}
// Check that this refresh token exists in the user's stored tokens
const tokenExists = await Promise.any(
user.refreshTokens.map(ht => bcrypt.compare(token, ht).then(match => {
if (match) return ht;
throw new Error('no match');
}))
).catch(() => null);
if (!tokenExists) {
// Possible token reuse attack: clear all refresh tokens
user.refreshTokens = [];
await user.save({ validateBeforeSave: false });
return res.status(403).json({ message: 'Token reuse detected. All sessions revoked.' });
}
// Remove the old refresh token
user.refreshTokens = user.refreshTokens.filter(ht => ht !== tokenExists);
// Issue new tokens (rotation)
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
const newHashedRT = await bcrypt.hash(newRefreshToken, 10);
user.refreshTokens.push(newHashedRT);
await user.save({ validateBeforeSave: false });
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
return res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(500).json({ message: 'Server error', error: err.message });
}
};
Notice the refresh token rotation pattern: every time a refresh token is used, the old one is invalidated and a new one is issued. If someone tries to reuse an old refresh token, all tokens for that user are wiped, forcing a fresh login.
6d. Logout
exports.logout = async (req, res) => {
try {
const token = req.cookies.refreshToken;
if (token) {
const payload = verifyRefreshToken(token);
const user = await User.findById(payload.userId);
if (user) {
// Remove the matching refresh token
const remaining = [];
for (const ht of user.refreshTokens) {
const match = await bcrypt.compare(token, ht);
if (!match) remaining.push(ht);
}
user.refreshTokens = remaining;
await user.save({ validateBeforeSave: false });
}
}
res.clearCookie('refreshToken', {
httpOnly: true,
secure: true,
sameSite: 'Strict'
});
return res.json({ message: 'Logged out successfully' });
} catch (err) {
// Even if token verification fails, clear the cookie
res.clearCookie('refreshToken');
return res.json({ message: 'Logged out' });
}
};
Step 7: Authentication Middleware
This middleware sits in front of any route you want to protect. It extracts the access token from the Authorization header and verifies it.
File: middleware/authMiddleware.js
const { verifyAccessToken } = require('../utils/tokenUtils');
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Access token required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = verifyAccessToken(token);
req.user = decoded; // attach user info to request
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Access token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ message: 'Invalid access token' });
}
}
module.exports = authenticate;
Returning a specific code like TOKEN_EXPIRED helps the client know when to silently refresh the token instead of redirecting to a login page.
Step 8: Routes
File: routes/authRoutes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const authenticate = require('../middleware/authMiddleware');
// Public routes
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
// Protected route example
router.get('/profile', authenticate, (req, res) => {
res.json({ message: 'This is a protected route', user: req.user });
});
module.exports = router;
Step 9: Server Entry Point
File: server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/authRoutes');
const app = express();
// Middleware
app.use(express.json());
app.use(cookieParser());
// Routes
app.use('/api/auth', authRoutes);
// Connect to MongoDB and start server
mongoose.connect(process.env.MONGO_URI)
.then(() => {
console.log('Connected to MongoDB');
app.listen(process.env.PORT, () => {
console.log(`Server running on port ${process.env.PORT}`);
});
})
.catch(err => console.error('MongoDB connection error:', err));
Step 10: Test the API
Use any HTTP client (Postman, Insomnia, or cURL) to test:
Register
POST http://localhost:4000/api/auth/register
Content-Type: application/json
{
"name": "Alice",
"email": "[email protected]",
"password": "securePassword123"
}
Login
POST http://localhost:4000/api/auth/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "securePassword123"
}
Access Protected Route
GET http://localhost:4000/api/auth/profile
Authorization: Bearer <your_access_token>
Refresh Token
POST http://localhost:4000/api/auth/refresh
(cookie with refreshToken is sent automatically)
Common Security Pitfalls (and How to Avoid Them)
Building JWT authentication is not just about making it work. It is about making it safe. Here are the most common mistakes developers make:
| Pitfall | Risk | Solution |
|---|---|---|
| Storing JWTs in localStorage | Vulnerable to XSS attacks | Store refresh tokens in httpOnly cookies. Keep access tokens in memory only. |
| Using a weak secret | Tokens can be forged | Generate secrets with at least 256 bits of entropy using crypto.randomBytes(64). |
| Long-lived access tokens | Stolen tokens stay valid too long | Keep access token expiry short (5 to 15 minutes). Use refresh tokens for longevity. |
| No refresh token rotation | Token theft goes undetected | Rotate refresh tokens on every use. Invalidate all tokens if reuse is detected. |
| Not validating the algorithm | “none” algorithm attack | The jsonwebtoken library rejects “none” by default, but always specify your expected algorithm explicitly. |
| Putting sensitive data in the payload | JWTs are only encoded, not encrypted | Only include non-sensitive identifiers (user ID, role). Never put passwords or PII in the token. |
| Missing HTTPS in production | Tokens can be intercepted | Always use HTTPS. Set the secure flag on cookies. |
Bonus: Role-Based Access Control
Once authentication is in place, you often need authorization too. Here is a simple role-checking middleware you can chain after the authenticate middleware:
function authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
return res.status(403).json({ message: 'Forbidden: insufficient permissions' });
}
next();
};
}
// Usage:
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
To support this, add a role field to your User model and include it in the access token payload.
Access Token vs. Refresh Token: Quick Comparison
| Feature | Access Token | Refresh Token |
|---|---|---|
| Lifetime | Short (5-15 minutes) | Long (days to weeks) |
| Where stored | In-memory (JavaScript variable) | httpOnly cookie |
| Sent with every request? | Yes, in Authorization header | Only to the /refresh endpoint |
| Can be revoked? | Only by waiting for expiry (unless using a blocklist) | Yes, by removing from the database |
When to Consider Alternatives to JWT
JWT is not the right choice for every scenario. Consider alternatives if:
- You need instant token revocation across all endpoints (session-based auth may be simpler)
- Your tokens grow large due to many claims (look into opaque tokens with a token introspection endpoint)
- You are building a monolithic server-rendered app where sessions work perfectly fine
For APIs, microservices, and SPAs, JWT remains an excellent choice.
Summary
Here is a quick recap of everything we built:
- Set up a Node.js + Express project with MongoDB
- Created a User model with password hashing via bcrypt
- Built utility functions to generate and verify JWTs
- Implemented register, login, refresh, and logout endpoints
- Added an authentication middleware to protect routes
- Implemented refresh token rotation with reuse detection
- Covered common security pitfalls and how to prevent them
You now have a solid, production-grade JWT authentication system in Node.js. From here, you can extend it with email verification, password reset flows, rate limiting, and more.
Frequently Asked Questions
What is JWT authentication in Node.js?
JWT (JSON Web Token) authentication in Node.js is a method where the server issues a digitally signed token after a user logs in. The client sends this token with subsequent requests, and the server verifies its signature to confirm the user’s identity without maintaining server-side sessions.
Where should I store JWTs on the client side?
Store access tokens in memory (a JavaScript variable) and refresh tokens in httpOnly, secure cookies. Avoid localStorage or sessionStorage for tokens because they are accessible via JavaScript and vulnerable to XSS attacks.
How long should a JWT access token last?
A common best practice is 5 to 15 minutes. The short lifespan limits the damage window if a token is compromised. Use refresh tokens to issue new access tokens without requiring the user to log in again.
What happens when a JWT expires?
When an access token expires, the server returns a 401 response. The client should then call the refresh endpoint with the refresh token to get a new access token. If the refresh token is also expired or invalid, the user must log in again.
Is JWT better than session-based authentication?
It depends on your architecture. JWT is ideal for stateless APIs, microservices, and single-page applications. Session-based authentication can be simpler for traditional server-rendered applications. JWT scales more easily because no session store is needed on the server.
Can a JWT be revoked?
JWTs are stateless by design, so they cannot be individually revoked before expiry unless you implement a token blocklist (which adds server-side state). For refresh tokens, you can revoke them by removing them from the database, which prevents new access tokens from being issued.
What is refresh token rotation and why does it matter?
Refresh token rotation means issuing a new refresh token every time the old one is used, and invalidating the old one. If an attacker steals a refresh token and the legitimate user also uses it, the server detects the reuse and revokes all tokens for that user, stopping the attack.

