Authentication Strategies
Authentication verifies the identity of an incoming request. Secure production systems never store plain text passwords, prefer modern cryptographic key derivation functions, and pick stateful or stateless session models based on scalability and revocation needs.
Credential Verification:
Client Password ──▶ Argon2 / Bcrypt Hash Check ──▶ Issued Auth Artifact
│
┌───────────────────────────────────────────────┴───────────────────────────────┐
▼ ▼
Stateless (JWT Pair) Stateful (Session Cookie)
- Access Token (Short-lived: 15m) - Opaque Session ID
- Refresh Token (Long-lived: 7d, rotated) - In-memory/Redis lookup
- Encoded claims signed with private key - Revocation is instant
1. Password Hashing: bcrypt vs. argon2
General-purpose hash algorithms (MD5, SHA-256, SHA-512) are built for throughput and are vulnerable to brute-force attacks on GPUs/ASICs. Password hashing functions must be deliberately slow and resource-heavy.
- bcrypt: A Battle-tested key derivation function based on the Blowfish cipher. It is primarily CPU-hard (controlled via work factor rounds, typically
10to12). - argon2: The winner of the Password Hashing Competition (PHC). It is both CPU-hard and memory-hard, providing resistance against custom hardware and GPU-assisted cracking. Use the
argon2idvariant, which combines data-independent and data-dependent memory access.
Bash
npm install argon2 bcrypt
JavaScript
import argon2 from 'argon2';
import bcrypt from 'bcrypt';
// Argon2id Implementation (Recommended modern default)
export async function hashPasswordArgon(plainPassword) {
return await argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 2 ** 16, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 1
});
}
export async function verifyPasswordArgon(hash, plainPassword) {
// Constant-time comparison handled internally to prevent timing attacks
return await argon2.verify(hash, plainPassword);
}
// bcrypt Alternative
const SALT_ROUNDS = 12;
export async function hashPasswordBcrypt(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
export async function verifyPasswordBcrypt(plainPassword, hash) {
return await bcrypt.compare(plainPassword, hash);
}
2. Stateless Token Authentication: JWT (Access & Refresh Tokens)
A JSON Web Token (JWT) contains three base64url-encoded parts separated by dots: Header.Payload.Signature.
- Signature: Created by hashing the header and payload with a private secret or asymmetric key (
RS256/EdDSA). - Storage Threat Model: Never store tokens in browser
localStorageorsessionStorage(they are directly accessible via Cross-Site Scripting / XSS). Keep the Access Token in memory (or private module variable) and the Refresh Token in anhttpOnly,Securecookie.
Bash
npm install jsonwebtoken
JavaScript
// utils/jwt.js
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
export function generateTokenPair(user) {
const payload = {
sub: user.id,
role: user.role
};
const accessToken = jwt.sign(payload, ACCESS_SECRET, {
expiresIn: '15m',
issuer: 'api.domain.com',
audience: 'app.domain.com'
});
const refreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, {
expiresIn: '7d',
issuer: 'api.domain.com'
});
return { accessToken, refreshToken };
}
export function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_SECRET, {
issuer: 'api.domain.com',
audience: 'app.domain.com'
});
}
export function verifyRefreshToken(token) {
return jwt.verify(token, REFRESH_SECRET, {
issuer: 'api.domain.com'
});
}
Refresh Token Rotation & Invalidation Flow
To mitigate token theft, issue a new Refresh Token on every refresh call and track token families in Redis:
JavaScript
// controllers/auth.controller.js
export async function refresh(req, res) {
const incomingRefreshToken = req.cookies.refreshToken;
if (!incomingRefreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}
try {
const decoded = verifyRefreshToken(incomingRefreshToken);
// Check if token was already used or blacklisted in Redis
const isRevoked = await redis.get(`bl:${incomingRefreshToken}`);
if (isRevoked) {
// Possible token theft: invalidate all sessions for this user family
await redis.del(`user_session:${decoded.sub}`);
return res.status(403).json({ error: 'Token reuse detected. Please log in again.' });
}
// Blacklist previous refresh token until its expiration
await redis.set(`bl:${incomingRefreshToken}`, '1', 'EX', 7 * 86400);
// Issue brand-new pair
const user = await db.user.findUnique({ where: { id: decoded.sub } });
const { accessToken, refreshToken: newRefreshToken } = generateTokenPair(user);
// Send new refresh token in secured cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/v1/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.status(200).json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
}
3. Stateful Session-Based Auth with Hardened Cookies
Stateful auth uses an opaque session identifier stored in client cookies. The server stores user state in Redis or a database.
JavaScript
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { redis } from '../config/redis.js';
export function configureSession(app) {
app.use(
session({
store: new RedisStore({ client: redis, prefix: 'sess:' }),
name: '__Host-sid', // __Host- prefix guarantees Secure, path=/, and no subdomains
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Inaccessible to document.cookie (Defends against XSS exfiltration)
secure: process.env.NODE_ENV === 'production', // Transmitted strictly over HTTPS
sameSite: 'lax', // Defends against Cross-Site Request Forgery (CSRF)
path: '/',
maxAge: 1000 * 60 * 60 * 8 // 8 hours
}
})
);
}
httpOnly: true: JavaScript cannot read the cookie string, preventing stolen sessions via injected XSS scripts.SameSite: 'strict' | 'lax':Strict: The cookie is never sent in cross-site requests (e.g., following a link from an external site).Lax: Default safe option. The cookie is withheld on cross-site subrequests (images, iframes), but sent when a user navigates to the origin site directly (top-level GET).
Secure: true: The browser sends the cookie only when the connection uses TLS/HTTPS.
4. OAuth 2.0 with Passport.js (Google Strategy)
OAuth 2.0 delegates authentication to an Identity Provider (IdP) without exposing user passwords to the consuming application.
Bash
npm install passport passport-google-oauth20
JavaScript
// config/passport.js
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'https://api.domain.com/api/v1/auth/google/callback',
scope: ['profile', 'email']
},
async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
const googleId = profile.id;
// Upsert user into database
let user = await db.user.findFirst({
where: { OR: [{ googleId }, { email }] }
});
if (!user) {
user = await db.user.create({
data: {
email,
name: profile.displayName,
googleId,
avatar: profile.photos?.[0]?.value
}
});
}
return done(null, user);
} catch (err) {
return done(err, null);
}
}
)
);
// Routes
// 1. Redirect to Google Consent Screen
app.get('/api/v1/auth/google', passport.authenticate('google', { session: false }));
// 2. Callback endpoint
app.get(
'/api/v1/auth/google/callback',
passport.authenticate('google', { session: false, failureRedirect: '/login' }),
(req, res) => {
// Generate internal JWTs or sessions for the authenticated user
const { accessToken, refreshToken } = generateTokenPair(req.user);
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
res.redirect(`https://app.domain.com/oauth/success?token=${accessToken}`);
}
);
Authorization: Role-Based Access Control (RBAC)
Authentication proves who you are; Authorization determines what you are allowed to do.
Request ──▶ [ verifyToken ] ──▶ req.user attached ──▶ [ checkRole / checkPermission ] ──▶ Next()
Middleware Implementation
JavaScript
// middleware/auth.middleware.js
import { verifyAccessToken } from '../utils/jwt.js';
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ status: 'fail', message: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
try {
const payload = verifyAccessToken(token);
req.user = payload; // { sub: 'usr_123', role: 'editor' }
next();
} catch (err) {
return res.status(401).json({ status: 'fail', message: 'Invalid or expired token' });
}
}
// Hierarchical Role Authorization Middleware
export function authorizeRoles(...allowedRoles) {
return (req, res, next) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
return res.status(403).json({
status: 'fail',
message: 'Forbidden: Insufficient privileges'
});
}
next();
};
}
// Granular Permission Matrix Middleware
const ROLE_PERMISSIONS = {
viewer: ['read:articles'],
editor: ['read:articles', 'create:articles', 'update:articles'],
admin: ['read:articles', 'create:articles', 'update:articles', 'delete:articles', 'manage:users']
};
export function requirePermission(permission) {
return (req, res, next) => {
const userRole = req.user?.role;
const permissions = ROLE_PERMISSIONS[userRole] || [];
if (!permissions.includes(permission)) {
return res.status(403).json({
status: 'fail',
message: `Forbidden: Missing required permission [${permission}]`
});
}
next();
};
}
JavaScript
// routes/article.routes.js
import { Router } from 'express';
import { authenticate, requirePermission } from '../middleware/auth.middleware.js';
const router = Router();
router.delete(
'/articles/:id',
authenticate,
requirePermission('delete:articles'),
(req, res) => res.json({ message: 'Article deleted successfully' })
);
Backend Security Hardening
1. Setting HTTP Security Headers with Helmet
Helmet adds secure HTTP response headers to defend against clickjacking, sniffed MIME types, and cross-site injections.
Bash
npm install helmet
JavaScript
import helmet from 'helmet';
app.use(
helmet({
// Content Security Policy: Whitelist origins that can execute scripts / load styles
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://cdn.domain.com'],
connectSrc: ["'self'", 'https://api.domain.com'],
frameAncestors: ["'none'"] // Blocks site from being embedded inside an iframe (Clickjacking defense)
}
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' },
dnsPrefetchControl: { allow: false },
frameguard: { action: 'deny' }, // Older X-Frame-Options equivalent
hidePoweredBy: true, // Strips 'X-Powered-By: Express' (prevents fingerprinting)
hsts: { // Enforces HTTPS for 1 year including subdomains
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
noSniff: true, // Sets X-Content-Type-Options: nosniff
xssFilter: true
})
);
2. Defending Against Injection & Web Attacks
Attack Vector Vulnerable Pattern Defense
───────────────────────────────────────────────────────────────────────────────────────────
SQL Injection String concatenation in raw SQL Parameterized queries ($1, ?), ORMs
NoSQL Injection Passing unvalidated objects ($ne, $gt) Input schema casting (Zod), mongo-sanitize
XSS Trusting untrusted HTML or reflections Contextual encoding, Strict CSP, httpOnly cookies
CSRF Unchecked cross-origin mutations SameSite cookies, CSRF Double-Submit Tokens
Timing Attacks Standard equality checks (===) crypto.timingSafeEqual()
SQL Injection
- Vulnerable:
db.query("SELECT * FROM users WHERE email = '" + req.body.email + "'") - Defended: Use parameterized inputs where the SQL engine compiles the query template before binding values:
JavaScript
// Parameterized query using pg
await pool.query('SELECT id, name FROM users WHERE email = $1', [req.body.email]);
NoSQL Injection (MongoDB)
- Vulnerable: Sending
{ "username": "admin", "password": { "$ne": null } }bypasses standard equality logic. - Defended: Enforce rigid input validation using Zod schemas and sanitize raw query keys:
Bash
npm install express-mongo-sanitize
JavaScript
import mongoSanitize from 'express-mongo-sanitize';
// Strips out any keys starting with '$' or containing '.'
app.use(mongoSanitize());
Cross-Site Scripting (XSS)
Never trust user inputs rendered directly back into HTML interfaces. Clean output using sanitizers:
Bash
npm install sanitize-html
JavaScript
import sanitizeHtml from 'sanitize-html';
const cleanBody = sanitizeHtml(req.body.untrustedContent, {
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
allowedAttributes: { a: ['href'] }
});
Cross-Site Request Forgery (CSRF)
If using cookie-based auth, a rogue site can force an authenticated browser to fire requests to your backend (e.g., via an image tag or hidden form).
- Use
SameSite=LaxorSameSite=Strictcookies. - For legacy browsers or cross-origin setups, use the Double Submit Cookie pattern or custom headers (
x-requested-withor custom API tokens). Since modern SPA/API architectures rely heavily on theAuthorization: Bearer <token>header, CSRF is naturally mitigated because browsers do not automatically attach headers cross-origin without prior CORS approval.
Timing Attacks
Using standard string equality operators (=== or ==) checks character by character and exits on the first mismatch. Attackers can measure response time variations to infer secret tokens or signatures byte-by-byte.
JavaScript
import crypto from 'node:crypto';
export function timingSafeCheck(userInput, actualSecret) {
const userBuffer = Buffer.from(userInput);
const secretBuffer = Buffer.from(actualSecret);
// If byte lengths do not match, comparison fails
if (userBuffer.length !== secretBuffer.length) {
return false;
}
// Executes in constant time regardless of where mismatches occur
return crypto.timingSafeEqual(userBuffer, secretBuffer);
}
3. Rate-Limiting & DDoS Mitigation
Defend backend services against volumetric floods, denial-of-service, and brute-force credential stuffing by placing a rate-limiting tier in front of endpoints.
Bash
npm install express-rate-limit rate-limit-redis
JavaScript
// middleware/rateLimiter.js
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { redis } from '../config/redis.js';
// Global API Rate Limiter
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // Limit each IP to 100 requests per window
standardHeaders: 'draft-7', // Return standard RateLimit-* headers
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args)
}),
message: {
status: 429,
message: 'Too many requests from this IP, please try again after 15 minutes'
}
});
// Strict Limiter for Sensitive Endpoints (Login, Password Reset)
export const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
limit: 5, // 5 failed attempts per hour
skipSuccessfulRequests: true, // Only count 4xx/5xx failures
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'rl:auth:'
}),
message: {
status: 429,
message: 'Too many failed login attempts. Account temporarily locked for 1 hour.'
}
});
JavaScript
// Mount limiters across the Express stack
app.use('/api/', apiLimiter);
app.use('/api/v1/auth/login', authLimiter);
