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/registerwith email and password - Login: POST to
/api/auth/login, receive an access token - Access protected route: GET
/api/profilewith headerAuthorization: Bearer YOUR_TOKEN - Refresh: POST to
/api/auth/refreshwhen the access token expires - Logout: POST to
/api/auth/logoutto 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
securecookie 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, stored only in environment variables
- Validate the algorithm: Pass
{ algorithms: ['HS256'] }tojwt.verifyto prevent algorithm confusion attacks - Rate-limit auth endpoints: Use
express-rate-limitto block brute-force attempts - Never put sensitive data in the payload: JWT payloads are base64-encoded, not encrypted
Common JWT Mistakes to Avoid
| Mistake | Fix |
|---|---|
| Storing tokens in localStorage | Use HttpOnly cookies for refresh tokens |
| Hardcoding secrets in code | Always use environment variables |
| Using long-lived access tokens | Keep them under 30 minutes |
| No way to revoke tokens | Track refresh tokens server-side |
| Skipping bcrypt for passwords | Always hash with at least 12 rounds |
Frequently Asked Questions
What is the difference between an access token and a refresh token?
An access token is short-lived and sent with every API request to authenticate the user. A refresh token is long-lived and used solely to request a new access token when the old one expires, without forcing the user to log in again.
Should I store JWTs in localStorage or cookies?
For refresh tokens, always use HttpOnly cookies to protect against XSS attacks. Access tokens can be stored in memory (a JavaScript variable) and re-fetched via the refresh endpoint when the page reloads.
How do I invalidate a JWT before it expires?
Since JWTs are stateless, you cannot truly invalidate them without server-side tracking. The common approach is to maintain a blacklist of revoked tokens or a whitelist of valid refresh tokens in a fast database like Redis.
Is JWT secure enough for production?
Yes, when implemented correctly. Use HTTPS, strong secrets, short expiration times, HttpOnly cookies for refresh tokens, and token rotation. Combined with rate limiting and proper password hashing, JWT is widely used in production by major companies.
Can I use JWT with sessions?
You can, but it usually defeats the purpose. JWT shines in stateless architectures. If you need server-side session control, consider traditional session cookies instead, or use a hybrid where refresh tokens are session-backed.
Wrapping Up
You now have a complete, production-ready JWT authentication system in Express.js with token generation, secure middleware verification, refresh token rotation, and proper logout handling. From here, you can extend this with role-based access control, social login providers, email verification, or two-factor authentication.
The key takeaway: JWT is powerful but unforgiving. Stick to the security best practices outlined here, and your API will be both fast and secure. Happy coding from the team at GeminiWeb.


