How to Implement JWT Authentication in Node.js + Express (2015)
Short answer: Use jsonwebtoken to sign tokens with a secret, verify them in Express middleware, store userId in payload. Sign access tokens with short expiry (15 min), issue refresh tokens with longer expiry (30 days) stored server-side.
In 2015, mobile APIs needed stateless auth — no sessions, no cookies. JWT filled that gap. Here's the production implementation we shipped, including the refresh token pattern we got wrong the first time.
Install
npm install express jsonwebtoken bcryptjs
# Node.js 4.x LTS (released October 2015) — first LTS with ES6 support
Token Generation
// auth/tokens.js
var jwt = require('jsonwebtoken');
var ACCESS_SECRET = process.env.JWT_SECRET; // 32+ random bytes
var REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// Access token: short-lived, stateless
function generateAccessToken(userId, role) {
return jwt.sign(
{ userId: userId, role: role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
}
// Refresh token: longer-lived, stored in DB
function generateRefreshToken(userId) {
return jwt.sign(
{ userId: userId, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '30d' }
);
}
module.exports = { generateAccessToken, generateRefreshToken };
Login Endpoint
// routes/auth.js
var express = require('express');
var bcrypt = require('bcryptjs');
var db = require('../db');
var tokens = require('../auth/tokens');
var router = express.Router();
router.post('/login', function(req, res) {
var email = req.body.email;
var password = req.body.password;
db.query('SELECT * FROM users WHERE email = ?', [email], function(err, rows) {
if (err || rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
var user = rows[0];
bcrypt.compare(password, user.password_hash, function(err, valid) {
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
var accessToken = tokens.generateAccessToken(user.id, user.role);
var refreshToken = tokens.generateRefreshToken(user.id);
// Store refresh token in DB (not just in client)
// Critical: allows invalidation on logout/password change
db.query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[user.id, refreshToken],
function() {
res.json({
accessToken: accessToken,
refreshToken: refreshToken,
expiresIn: 900 // 15 minutes in seconds
});
}
);
});
});
});
module.exports = router;
Auth Middleware
// middleware/authenticate.js
var jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
var header = req.headers['authorization'];
if (!header) {
return res.status(401).json({ error: 'No token provided' });
}
// Standard: "Authorization: Bearer <token>"
var parts = header.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({ error: 'Token format invalid' });
}
var token = parts[1];
jwt.verify(token, process.env.JWT_SECRET, function(err, decoded) {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Token invalid' });
}
req.userId = decoded.userId;
req.userRole = decoded.role;
next();
});
}
// Role-based access: wrap authenticate
function requireRole(role) {
return [authenticate, function(req, res, next) {
if (req.userRole !== role && req.userRole !== 'admin') {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
}];
}
module.exports = { authenticate, requireRole };
Refresh Token Endpoint
// routes/auth.js (continued)
router.post('/refresh', function(req, res) {
var refreshToken = req.body.refreshToken;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
// Verify JWT signature first
jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, function(err, decoded) {
if (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Then verify it exists in DB (not revoked)
db.query(
'SELECT * FROM refresh_tokens WHERE token = ? AND user_id = ? AND expires_at > NOW()',
[refreshToken, decoded.userId],
function(err, rows) {
if (err || rows.length === 0) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
// Rotate: delete old token, issue new pair
db.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
var newAccess = tokens.generateAccessToken(decoded.userId, rows[0].role);
var newRefresh = tokens.generateRefreshToken(decoded.userId);
db.query(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[decoded.userId, newRefresh]
);
res.json({ accessToken: newAccess, refreshToken: newRefresh });
}
);
});
});
Logout (Token Revocation)
router.post('/logout', authenticate, function(req, res) {
var refreshToken = req.body.refreshToken;
// Delete specific refresh token
db.query(
'DELETE FROM refresh_tokens WHERE user_id = ? AND token = ?',
[req.userId, refreshToken],
function() {
res.json({ message: 'Logged out' });
}
);
});
// Logout everywhere (e.g., after password change)
router.post('/logout-all', authenticate, function(req, res) {
db.query('DELETE FROM refresh_tokens WHERE user_id = ?', [req.userId], function() {
res.json({ message: 'All sessions terminated' });
});
});
refresh_tokens table
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token TEXT NOT NULL, -- Full JWT string
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT NOW(),
INDEX idx_user (user_id)
-- No unique index on token: TEXT column, use application-level check
) ENGINE=InnoDB;
The Mistake We Made First
We stored JWTs in localStorage. In 2015, this was common advice. The problem: XSS vulnerability reads localStorage from injected JavaScript.
Fix: Access token in memory (JS variable), refresh token in httpOnly cookie. The access token lives only for 15 minutes — an XSS attack has a narrow window. The refresh token in httpOnly cookie is inaccessible to JS entirely.
// Sending refresh token as httpOnly cookie instead of body
res.cookie('refreshToken', newRefresh, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'Strict',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days in ms
});
res.json({ accessToken: newAccess });
This pattern held up. In 2025, it's still the recommended approach for web apps.
Usage on Protected Routes
var auth = require('./middleware/authenticate');
// Public route
app.get('/api/products', getProducts);
// Requires any authenticated user
app.get('/api/orders', auth.authenticate, getOrders);
// Requires admin role
app.delete('/api/users/:id', auth.requireRole('admin'), deleteUser);