JWT authentication is one of the most widely used patterns for securing Node.js APIs. The server issues a signed token when a user authenticates, the client sends that token with subsequent requests, and a middleware layer verifies it before the request reaches any protected route handler.
This guide builds a complete implementation: project setup, user registration with hashed passwords, login with token issuance, protected route middleware, and the security practices that matter before moving to production.
What this covers:
Project setup with Express, jsonwebtoken, bcryptjs, and dotenv
User registration with bcrypt password hashing
Login and JWT issuance
Authentication middleware for protecting routes
Testing the flow with curl
Token expiry, refresh tokens, and secure storage
Step 1: Project Setup
mkdir node-jwt-api && cd node-jwt-api
npm init -y
npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemonAdd a dev script to package.json:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}Create a .env file:
PORT=4000
JWT_SECRET=replace_this_with_a_long_random_stringThe JWT_SECRET should be a randomly generated string of at least 32 characters. A weak or guessable secret undermines the entire security model. Generate one with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Add .env to .gitignore immediately.
Step 2: Basic Express Server
// server.js
require('dotenv').config();
const express = require('express');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 4000;
app.get('/', (req, res) => {
res.json({ message: 'API is running' });
});
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));npm run devStep 3: User Registration with Password Hashing
In production, users are stored in a database. For this guide, an in-memory array demonstrates the pattern. The hashing logic is identical regardless of storage.
const bcrypt = require('bcryptjs');
const users = []; // replace with database queries in production
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: 'Username and password are required' });
}
const existing = users.find(u => u.username === username);
if (existing) {
return res.status(409).json({ message: 'Username already taken' });
}
const hashedPassword = await bcrypt.hash(password, 12);
users.push({ username, password: hashedPassword });
res.status(201).json({ message: 'User registered successfully' });
});The salt rounds value of 12 is a reasonable balance between security and performance. The OWASP password storage cheat sheet recommends a minimum of 10 rounds; 12 is a common production choice. Higher values increase the time to hash and verify, which is desirable for slowing brute-force attempts.
The duplicate username check prevents a user from registering the same username twice, which would make login ambiguous.
Step 4: Login and Token Issuance
const jwt = require('jsonwebtoken');
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: 'Username and password are required' });
}
const user = users.find(u => u.username === username);
// Use the same error message for "not found" and "wrong password"
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const token = jwt.sign(
{ username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token });
});Two security details worth noting:
The error message is identical for "user not found" and "wrong password". Distinct messages (User not found vs Invalid password) allow an attacker to enumerate valid usernames. Using the same message for both prevents this.
expiresIn: '15m' is intentionally short. A stolen 1-hour token is a 1-hour window for an attacker. A stolen 15-minute token is a much shorter window. Pair short access tokens with refresh tokens (covered below) to maintain sessions without requiring frequent logins.
Step 5: Authentication Middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1]; // "Bearer <token>"
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
const message =
err.name === 'TokenExpiredError'
? 'Token expired'
: 'Invalid token';
return res.status(401).json({ message });
}
req.user = decoded;
next();
});
}Distinguishing TokenExpiredError from other verification failures allows the client to handle each case appropriately: an expired token warrants a refresh attempt; an invalid token warrants re-authentication.
Apply the middleware to protected routes:
app.get('/profile', authenticateToken, (req, res) => {
res.json({ username: req.user.username });
});For routes where the middleware applies to every handler in a group, apply it at the router level:
const protectedRouter = express.Router();
protectedRouter.use(authenticateToken);
protectedRouter.get('/profile', (req, res) => {
res.json({ username: req.user.username });
});
protectedRouter.get('/settings', (req, res) => {
res.json({ /* settings data */ });
});
app.use('/api', protectedRouter);Step 6: Test the Flow with curl
Register
curl -X POST http://localhost:4000/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"mypassword"}'Expected response:
{ "message": "User registered successfully" }Login
curl -X POST http://localhost:4000/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"mypassword"}'Expected response:
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }Access a Protected Route
curl http://localhost:4000/profile \
-H "Authorization: Bearer <token>"Expected response:
{ "username": "alice" }Without a token: 401 with Access token required. With an expired token: 401 with Token expired. With a tampered token: 401 with Invalid token.
Step 7: Refresh Tokens
With short-lived access tokens, a mechanism for issuing new access tokens without requiring re-login is necessary. Refresh tokens are long-lived tokens that are stored in httpOnly cookies and exchanged for new access tokens.
// On login, issue both an access token and a refresh token
app.post('/login', async (req, res) => {
// ... validation and password check ...
const accessToken = jwt.sign(
{ username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ username: user.username },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store the refresh token (database in production, not in-memory)
user.refreshToken = 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 endpoint
app.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
jwt.verify(refreshToken, process.env.REFRESH_SECRET, (err, decoded) => {
if (err) return res.sendStatus(403);
const newAccessToken = jwt.sign(
{ username: decoded.username },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});// Logout — clear the refresh token
app.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
const user = users.find(u => u.refreshToken === refreshToken);
if (user) user.refreshToken = null;
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out' });
});Add cookie-parser to read cookies:
npm install cookie-parserconst cookieParser = require('cookie-parser');
app.use(cookieParser());Add REFRESH_SECRET to .env — use a different value from JWT_SECRET.
Security Considerations
Token payload. JWTs are base64-encoded, not encrypted. Anyone with the token can read the payload. Include only the minimum necessary data (user ID, role). Never include passwords, sensitive personal information, or anything that should remain private.
httpOnly cookies vs localStorage. Storing access tokens in localStorage exposes them to XSS attacks — any script running on the page can read localStorage. Storing access tokens in memory (React state) and refresh tokens in httpOnly cookies is the more secure pattern for web applications. httpOnly cookies are inaccessible to JavaScript.
Token invalidation. JWTs are stateless and cannot be revoked before they expire. For applications where immediate invalidation matters (account deletion, password change), maintain a server-side blocklist of invalidated token IDs (jti claim). Check the blocklist in the middleware.
Rate limiting. Login endpoints are targets for brute-force attacks. Apply rate limiting to /login and /register:
npm install express-rate-limitconst rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { message: 'Too many login attempts. Try again later.' },
});
app.post('/login', loginLimiter, async (req, res) => {
/* ... */
});Key Takeaways
Generate
JWT_SECRETandREFRESH_SECRETwith a cryptographically random generator. Store them in.env, never in code.Use
bcryptwith at least 10 salt rounds (12 is a reasonable production value) for password hashing.Return identical error messages for "user not found" and "wrong password" to prevent username enumeration.
Keep access tokens short-lived (15 minutes). Use refresh tokens stored in
httpOnlycookies for session continuity.Distinguish
TokenExpiredErrorfrom other JWT errors in middleware so clients can attempt a refresh rather than immediately redirecting to login.Apply rate limiting to authentication endpoints to slow brute-force attempts.
Never put sensitive data in the JWT payload — it is base64-encoded and readable by anyone with the token.
Conclusion
The pattern built here — bcrypt for password hashing, JWT for stateless authentication, middleware for route protection, and refresh tokens for session continuity — covers the core requirements of a secure Node.js API. Each piece addresses a specific threat: hashing protects stored passwords, short-lived tokens limit exposure windows, middleware centralizes verification, and refresh tokens avoid requiring re-login on every expiry.
Moving to production means replacing the in-memory user store with a real database, adding rate limiting, and reviewing the token storage approach for the client type being served.
Building this for a specific framework or hitting a specific authentication edge case? Share it in the comments.




