Introduction
JSON Web Tokens (JWT) are a popular and compact way to authenticate users in modern web applications. A JWT login system lets clients present a signed token instead of sending a username/password on every request. This guide demonstrates how to construct a secure, production-ready JWT login system in Node.js (Express), featuring examples, middleware, refresh tokens, and best practices for security.
What is JWT and why use it?
A JWT is a signed JSON object (header.payload.signature). It proves the userβs identity without server-side session storage (stateless), which makes it great for scalable APIs and microservices. But JWTs must be handled carefully β storing them insecurely or making them too long-lived introduces risk. Trusted tutorials stress these points and caution against storing tokens insecurely (for example, in plain localStorage
).
High-level design of a JWT login system
Register: The user signs up; the server stores a hashed password (bcrypt).
Login: The server verifies credentials and issues an access token (a short-lived JWT) and a refresh token (a longer-lived, revocable token).
Protected routes: A middleware checks the access token on requests.
Refresh: When the access token expires, the client requests a new one using the refresh token.
Logout/revocation: Invalidate refresh tokens server-side (DB/Redis) or use rotation.
Many up-to-date guides recommend short-lived access tokens + refresh tokens for safety, and server-side storage or revocation for refresh tokens.
Prerequisites (packages & environment)
Create a Node.js + Express project. Typical packages:
express
β web framework
jsonwebtoken
β sign/verify JWTs
bcrypt
or bcryptjs
β hash passwords
dotenv
β manage secrets in env variables
cookie-parser
β if using HTTP-only cookies
Database: MongoDB (Mongoose), PostgreSQL, MySQL, or Redis for token revocation
Install example
npm init -y
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
# and your DB driver, e.g. mongoose or pg
Minimal secure architecture decisions (recommendations)
Use HTTPS always in production.
Use short-lived access tokens (e.g., 5β15 minutes) and longer-lived refresh tokens.
Store access tokens in memory or in an httpOnly cookie; avoid localStorage
for sensitive tokens. DigitalOcean and Linode articles highlight storage risks and prefer httpOnly cookies for many apps.
Keep your JWT secret in environment variables or secret manager (process.env.JWT_SECRET
).
Hash passwords with bcrypt
before saving to DB.
Example. Simple JWT Login System (Express + MongoDB-like pseudo code)
Below is a practical example covering registration, login (issue tokens), middleware, refresh, and logout. Adapt DB calls to your database (Mongoose/Sequelize/etc.).
Note: This example uses HTTP-only cookies for refresh tokens and returns the access token in the JSON response. You can also store the access token as an httpOnly cookie.
1) Environment variables (.env
)
PORT=4000
JWT_ACCESS_SECRET=very_secret_access_key
JWT_REFRESH_SECRET=another_secret_for_refresh
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
2) Simple user model (pseudo)
// users collection fields suggested: { id, email, passwordHash, refreshTokenId? }
3) Utility: create tokens
const jwt = require('jsonwebtoken');
function createAccessToken(payload) {
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXPIRY });
}
function createRefreshToken(payload) {
// include a jti (unique id) if you plan to revoke/track refresh tokens
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXPIRY });
}
4) Register route (hash password)
const bcrypt = require('bcryptjs');
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// validate email & password...
const passwordHash = await bcrypt.hash(password, 10);
// Save user { email, passwordHash } to DB
res.status(201).json({ message: 'User created' });
});
5) Login route (issue tokens)
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// find user by email from DB
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
// create payload (avoid sensitive data)
const payload = { userId: user._id, role: user.role };
const accessToken = createAccessToken(payload);
const refreshToken = createRefreshToken({ ...payload, tokenId: someUniqueId() });
// store refresh token id or token in DB for revocation/rotation
await saveRefreshTokenToDB(user._id, refreshToken /* or tokenId */);
// set refresh token as httpOnly cookie
res.cookie('jid', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/refresh_token' // limit cookie scope
});
res.json({ accessToken }); // client uses this for Authorization: Bearer <token>
});
(Using httpOnly cookie for refresh token reduces XSS risk; cookies must be protected with HTTPS and proper SameSite.)
6) Middleware: protect routes (verify access token)
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader) return res.status(401).json({ error: 'No token' });
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.JWT_ACCESS_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
req.user = user; // contains userId and role
next();
});
}
// usage
app.get('/protected', authenticateToken, (req, res) => {
res.json({ message: 'Protected data', user: req.user });
});
7) Refresh endpoint (rotate / reissue access token)
app.post('/refresh_token', async (req, res) => {
const token = req.cookies.jid;
if (!token) return res.status(401).json({ ok: false, accessToken: '' });
try {
const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
// Check tokenId against DB (revoked/valid)
const valid = await isRefreshTokenValid(payload.userId, payload.tokenId);
if (!valid) return res.status(401).json({ ok: false, accessToken: '' });
// Optionally rotate refresh token: issue a new refresh token (with new tokenId) and store new id in DB
const newRefreshTokenId = someUniqueId();
const newRefreshToken = createRefreshToken({ userId: payload.userId, tokenId: newRefreshTokenId });
await replaceRefreshTokenInDB(payload.userId, payload.tokenId, newRefreshTokenId);
res.cookie('jid', newRefreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/refresh_token' });
const accessToken = createAccessToken({ userId: payload.userId });
return res.json({ ok: true, accessToken });
} catch (e) {
return res.status(401).json({ ok: false, accessToken: '' });
}
});
8) Logout / revoke refresh tokens
app.post('/logout', async (req, res) => {
const token = req.cookies.jid;
if (token) {
// decode or verify refresh token then remove it from DB
try {
const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
await revokeRefreshToken(payload.userId, payload.tokenId);
} catch (e) {}
}
// Clear cookie
res.clearCookie('jid', { path: '/refresh_token' });
return res.json({ success: true });
});
Security best practices (practical checklist)
Use HTTPS and secure
cookies in production.
Do not store tokens in localStorage for sensitive apps; prefer httpOnly cookies or in-memory storage.
Use short-lived access tokens (minimize time window if leaked). Use refresh tokens for session continuity.
Hash passwords with bcrypt before storing.
Protect refresh tokens: store them server-side (DB/Redis) or use rotating refresh tokens and a revocation list.
Validate inputs and apply rate limiting and account lockouts to prevent brute force.
Keep secrets out of source control; use environment secrets or vaults.
Use proper JWT algorithms (HS256 is common; for high security, use RS256 with asymmetric keys and rotate keys carefully).
Log and monitor suspicious login or token activity.
Common pitfalls & how to avoid them
Keeping secret keys in code β use process.env
or secret manager.
Overly long-lived access tokens β use brief expiry and refresh flow.
No refresh token revocation β store token ids (jti) and revoke on logout or suspicious events.
Relying only on client-side checks β always verify tokens server-side.
Not verifying token signature/algorithms β use jsonwebtoken.verify()
and check the algorithm if needed.
Scaling & production tips
Use Redis to store/track refresh token ids and fast revocation.
Consider single sign-on or third-party identity (Auth0, Okta) if you do not want to manage auth in-house.
Add role-based access control (RBAC) inside the JWT payload (role
) and check it in the middleware. Many tutorials show role examples for real-world apps.
Rate-limit login and refresh endpoints to reduce abuse.
FAQs
Should I store JWTs in cookies or localStorage?
A: Cookies (httpOnly, secure, sameSite) are safer vs XSS; localStorage is vulnerable to XSS. For APIs used by SPAs, you can store the access token in memory and refresh via an HTTP-only cookie.
Do I need refresh tokens?
A: For long sessions without re-login, yes β use refresh tokens to renew short-lived access tokens and support revocation.
Summary
A robust JWT login system in Node.js uses short-lived access tokens plus revocable refresh tokens, secure password hashing, HTTPS, and careful token storage (httpOnly cookies or in-memory). Use jsonwebtoken
and bcrypt
, verify tokens in middleware, and implement refresh/rotation and revocation strategies for production safety. Following these patterns β supported by established guides β will give you a scalable and secure authentication flow.