Authentication is the front door of every application. Get it wrong and nothing else matters — your data, your users, your reputation are all at risk. In this article, written from the trenches of building auth systems for production SaaS platforms, Bablu Kumar Singh walks through a robust JWT-based authentication architecture for Node.js applications.
Password Hashing with bcrypt
Never store passwords in plaintext. Use bcrypt, which adds a per-user salt and is intentionally slow to resist brute-force attacks:
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}Use at least 12 salt rounds in production. Each additional round doubles the computation time, making offline attacks exponentially harder.
JWT Access Tokens
After verifying credentials, issue a short-lived JSON Web Token:
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const ACCESS_TOKEN_EXPIRY = '15m';
function generateAccessToken(user: { id: string; email: string; role: string }) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}Why 15 minutes? A short-lived access token limits the damage window if a token is stolen. The user re-authenticates transparently using a refresh token.
Refresh Token Rotation
Refresh tokens are long-lived credentials stored in the database. When a client exchanges a refresh token for a new access token, you rotate the refresh token to detect token theft:
import crypto from 'crypto';
async function generateRefreshToken(userId: string): Promise<string> {
const token = crypto.randomBytes(40).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await RefreshToken.create({ token, userId, expiresAt });
return token;
}
async function rotateRefreshToken(oldToken: string) {
const existing = await RefreshToken.findOne({ token: oldToken });
if (!existing || existing.expiresAt < new Date()) {
// Token not found or expired — possible theft, revoke all tokens for this user
if (existing) await RefreshToken.deleteMany({ userId: existing.userId });
throw new Error('Invalid refresh token');
}
// Delete old token and issue new one
await RefreshToken.deleteOne({ _id: existing._id });
const newToken = await generateRefreshToken(existing.userId);
const accessToken = generateAccessToken(
await User.findById(existing.userId)
);
return { accessToken, refreshToken: newToken };
}If someone replays an old refresh token, you know the family has been compromised and revoke everything.
Authentication Middleware
Protect routes by verifying the access token on every request:
import { Request, Response, NextFunction } from 'express';
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, ACCESS_TOKEN_SECRET) as jwt.JwtPayload;
req.user = { id: payload.sub!, email: payload.email, role: payload.role };
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}Login Endpoint
Bringing it all together:
router.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const accessToken = generateAccessToken({
id: user._id.toString(),
email: user.email,
role: user.role,
});
const refreshToken = await generateRefreshToken(user._id.toString());
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});Token Blacklisting for Logout
When a user logs out, invalidate their refresh token and blacklist the access token until it expires:
router.post('/auth/logout', authenticate, async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) await RefreshToken.deleteOne({ token: refreshToken });
// Blacklist the access token in Redis until its expiry
const token = req.headers.authorization!.split(' ')[1];
const payload = jwt.decode(token) as jwt.JwtPayload;
const ttl = payload.exp! - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`blacklist:${token}`, '1', 'EX', ttl);
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});Security Checklist
- Hash passwords with bcrypt (12+ salt rounds).
- Short-lived access tokens (15 minutes max).
- Rotate refresh tokens on every use.
- Store refresh tokens in httpOnly, secure, sameSite cookies — never localStorage.
- Blacklist access tokens on logout using Redis.
- Use HTTPS everywhere — tokens in plaintext over HTTP are useless.
- Implement rate limiting on login endpoints to prevent brute-force attacks.
Key Takeaways
- Authentication is a layered system — password hashing, token generation, token verification, and token revocation all play a role.
- Refresh token rotation is the gold standard for detecting stolen credentials.
- Never store sensitive tokens in localStorage — use httpOnly cookies.
- Treat authentication as an evolving concern — revisit it quarterly.
Get authentication right, and you build trust with every user who logs in.
