Authentication (AuthN) answers "Who are you?" — it verifies the identity of a user or system. Authorization (AuthZ) answers "What are you allowed to do?" — it determines what an authenticated identity can access or perform.
Authentication always happens first. You cannot meaningfully authorize someone whose identity you don't know.
| Aspect | Authentication (AuthN) | Authorization (AuthZ) |
|---|---|---|
| Question | Who are you? | What can you do? |
| Mechanism | Password, JWT, biometric, OAuth token | Roles, permissions, ACLs, policies |
| Output | Verified identity (user object / claims) | Allow or deny the requested action |
| HTTP status on failure | 401 Unauthorized | 403 Forbidden |
| Example | Logging in with email + password | Only admins can delete posts |
Authentication is the ID check at a venue entrance — proving who you are. Authorization is the wristband you get after — determining which areas you can enter. You can't get the wristband without showing ID first.
A common mistake is returning 401 when a user is authenticated but lacks permission — that should be 403. Interviewers notice this distinction.
| Type | Mechanism | Best For |
|---|---|---|
| Session + Cookie | Opaque session ID in httpOnly cookie; server holds state in Redis/DB | Traditional web apps, server-rendered pages, when instant revocation is needed |
| JWT | Self-contained signed token; server is stateless | SPAs, mobile apps, microservices, third-party API consumers |
| OAuth 2.0 / OIDC | Delegated auth via third-party IdP (Google, GitHub) | Social login, enterprise SSO, acting on behalf of a user in another system |
| API Keys | Opaque secret sent in header per request | Server-to-server, developer APIs, integrations without user context |
| Passwordless | Magic links, TOTP, WebAuthn/passkeys | High-security apps, great UX without password friction |
In practice, most MERN apps combine these: JWT for the primary user auth, OAuth for social login, and API keys for server-to-server webhooks.
Server stores session data. Each request carries an opaque ID. Server looks it up in Redis/DB to retrieve the session. State lives server-side.
Token is self-contained — it carries all the data needed. Server just verifies the signature on every request. No DB lookup required for auth.
| Trade-off | Stateful | Stateless |
|---|---|---|
| Revocation | Instant — delete the session record | Hard — token valid until expiry unless you add a blocklist |
| Scalability | Needs shared session store (Redis) across instances | Any server can verify without coordination |
| Request cost | DB/Redis lookup per request | CPU cost of signature verification only |
| Token size | Small cookie (~30 bytes) | Larger JWT (~300–500 bytes) |
| Suitable for | Web apps needing force-logout | APIs, microservices, mobile |
Cookies are small pieces of data the server sends to the browser via the Set-Cookie response header. The browser automatically sends them back on every subsequent request to the same domain via the Cookie request header — this automatic inclusion is what makes them useful for auth.
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Laxreq.cookies.sessionId, looks up the session, identifies the userThe key security flags — HttpOnly, Secure, and SameSite — are what separate a safe cookie from a vulnerable one. These are covered in depth in the Security segment.
A random, meaningless string (e.g. 4f3a9b...). Acts as a key into a server-side lookup table. The session store holds the actual user data. Without the store, the ID means nothing.
A structured, signed data structure (JWT). Contains the user data directly in its payload. Any party with the public key / secret can verify authenticity without contacting the issuer.
A session ID is like a coat-check ticket — it's just a number that points to your coat stored elsewhere. A JWT is like a notarized letter — it carries all the information about you and the notary's signature proves it hasn't been tampered with.
Every auth system has a trust chain. Understanding it prevents entire categories of bugs.
| Scenario | Trust Chain |
|---|---|
| Custom JWT auth | User trusts your server → Server signs token with a secret → Server trusts its own tokens (verified by secret) |
| OAuth / Social Login | User trusts Google → Google issues token to your app → Your server trusts Google's signature → Your server issues its own session/JWT |
| Microservices | Service A trusts your Auth Service → Auth Service issues JWT → Service B verifies with Auth Service's public key → Service B trusts the claim |
One shared secret used to both sign and verify. Fast. Simple. But: every verifier needs the secret. If a downstream service is compromised, the secret is exposed and attackers can forge tokens.
Private key signs (only the auth server has it). Public key verifies (shared freely). Downstream services never see the private key. Compromise of a verifier doesn't compromise token signing.
// HS256 — same secret for sign & verify const token = jwt.sign({ sub: userId }, process.env.JWT_SECRET, { algorithm: 'HS256' }); jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); // RS256 — private key signs, public key verifies const token = jwt.sign({ sub: userId }, privateKey, { algorithm: 'RS256' }); jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // any service can do this
Rule of thumb: Use HS256 when a single service both signs and verifies. Use RS256/ES256 when multiple services need to verify tokens — microservices, third-party resource servers, or when you publish a JWKS endpoint.
JWT claims are key-value pairs in the payload that assert facts about the token and its subject. There are three claim types:
iss, aud, and exp in production — not just the signature. The jsonwebtoken library accepts options: jwt.verify(token, secret, { issuer, audience, algorithms })
Credential stuffing is an attack where bad actors take username/password pairs from data breaches (billions are available for free) and automatically try them against your login endpoint. Because people reuse passwords, a significant percentage succeed.
It differs from brute force — the attacker isn't guessing; they're using credentials that actually work somewhere.
- bcrypt/argon2 — slow hashing makes offline attacks expensive (but doesn't stop online stuffing)
- Rate limiting — limit login attempts per IP per minute with express-rate-limit + Redis
- Bot detection / CAPTCHA — Google reCAPTCHA v3 (invisible) after a threshold of failures
- Breach detection — check submitted passwords against HaveIBeenPwned API (k-anonymity model — you never send the full hash)
- MFA — even if credentials match, the attacker still needs the second factor
- Anomaly detection — flag logins from new countries, devices, or IPs for step-up auth
Auth state in React is typically managed in one of three ways, each with trade-offs:
| Approach | How | Trade-off |
|---|---|---|
| React Context | AuthContext with user object, login/logout methods. Wrap app in AuthProvider. | Simple. Works for most apps. Re-renders entire tree on change. |
| Redux / Zustand | Auth slice in global store. Persisted to sessionStorage if needed. | Good for complex apps already using global state. Overhead for small apps. |
| React Query / SWR | Query the /me endpoint to always get fresh server state. Cache + refetch. | Source of truth is the server. Handles token expiry naturally. |
// Simple AuthContext pattern const AuthContext = createContext(null); export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(async () => { try { const res = await fetch('/api/auth/me', { credentials: 'include' }); if (res.ok) setUser(await res.json()); } finally { setLoading(false); } }, []); return ( <AuthContext.Provider value={{ user, loading, setUser }}> {!loading && children} </AuthContext.Provider> ); }
POST /auth/login with credentials (over HTTPS). credentials: 'include' must be set so cookies are sent cross-origin.bcrypt.compare().req.session.userId = user._id. express-session serialises the session object and stores it in Redis with a TTL. The session ID (a random string) is the only thing the client ever sees.Set-Cookie: connect.sid=s%3A...; Path=/; HttpOnly; Secure; SameSite=Lax in the response header.req.session.userId, looks up the session in Redis, and identifies the user — no DB query for auth.req.session.destroy() deletes the Redis record. Server sends an expired Set-Cookie to clear the browser cookie. Both sides are cleared.The session ID in the cookie is like a locker key. It's meaningless on its own — all your belongings (user data) are in the locker (Redis). Without access to the locker room (Redis), the key is useless to an attacker.
import session from 'express-session'; import RedisStore from 'connect-redis'; import { createClient } from 'redis'; const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); app.use(session({ secret: process.env.SESSION_SECRET, // long random string, rotate periodically resave: false, // don't save session if unmodified saveUninitialized: false, // don't create session until data is set store: new RedisStore({ client: redisClient }), cookie: { httpOnly: true, // JS cannot read the cookie (XSS protection) secure: process.env.NODE_ENV === 'production', // HTTPS only in prod sameSite: 'lax', // CSRF protection for most cases maxAge: 24 * 60 * 60 * 1000 // 24 hours in milliseconds } }));
| Option | Recommended Value | Why |
|---|---|---|
| secret | 32+ byte random string from env | Signs the session ID cookie to detect tampering. Never hardcode. |
| resave | false | Avoids unnecessary Redis writes on unchanged sessions. |
| saveUninitialized | false | Prevents creating empty sessions for unauthenticated visitors. Saves storage and aids GDPR compliance. |
| store | RedisStore | In-memory store (default) is lost on restart and doesn't scale across processes. |
secure: false in production means the cookie is sent over plain HTTP and can be intercepted. Always tie it to NODE_ENV.
The default MemoryStore in express-session stores sessions in the Node.js process heap. This causes three production problems:
- Memory leak — sessions accumulate and are never garbage collected under load; express-session itself logs a warning about this
- Lost on restart — every deploy, crash, or pod restart logs out all users
- No horizontal scaling — if you run 3 Node.js instances (PM2 cluster, Kubernetes pods), each has its own isolated memory store; requests routed to a different instance don't find the session
Redis solves all three: it persists across restarts (with AOF/RDB), is shared across all instances, and supports automatic key expiry (TTL) for session cleanup.
// connect-redis v7+ (peer depends on redis v4) import RedisStore from 'connect-redis'; import { createClient } from 'redis'; const client = createClient({ url: process.env.REDIS_URL }); client.on('error', err => console.error('Redis error:', err)); await client.connect(); // Pass into express-session store option store: new RedisStore({ client, prefix: 'sess:' })
connect-mongo stores sessions in MongoDB — convenient if you already have Mongo but adds latency vs Redis. Fine for low-traffic apps; prefer Redis for anything with real load.
| Flag | What it does | Attack it prevents |
|---|---|---|
| HttpOnly | JavaScript cannot access the cookie via document.cookie | XSS — even if attacker injects JS, they can't steal the session cookie |
| Secure | Cookie only sent over HTTPS connections | Man-in-the-middle — prevents cookie interception over plain HTTP |
| SameSite=Strict | Cookie never sent on any cross-site request (including top-level navigations) | CSRF — but breaks OAuth flows and links from external sites |
| SameSite=Lax | Cookie sent on same-site requests + top-level GET navigation from external sites | CSRF for state-changing actions (POST/PUT/DELETE) — browser default since 2020 |
| SameSite=None | Cookie sent on all cross-site requests — requires Secure flag | Needed for third-party contexts (iframes, cross-origin APIs) |
| Max-Age / Expires | Sets cookie lifetime; without it the cookie is session-only (deleted on browser close) | Controls how long a compromised cookie could be valid |
| Domain / Path | Scopes which URLs receive the cookie | Limits blast radius if subdomain is compromised |
HttpOnly; Secure; SameSite=Lax; Max-Age=86400 — this covers the most common attack vectors without breaking normal navigation flows.
Cross-Site Request Forgery (CSRF) exploits the fact that browsers automatically attach cookies to requests. An attacker's page can trigger a request to your API — the browser sends the session cookie, and your server thinks it's legitimate.
<!-- Victim visits attacker.com while logged in to yourbank.com --> <form action="https://yourbank.com/transfer" method="POST"> <input name="amount" value="10000"> <input name="to" value="attacker-account"> </form> <script>document.forms[0].submit();</script> <!-- Browser auto-sends session cookie → bank sees valid authenticated request -->
Modern browsers block cross-site cookie sending. SameSite=Lax is the browser default since 2020 and stops CSRF on POST/PUT/DELETE with no extra code.
Server generates a secret token per session. Client must include it in every state-changing request. Server verifies it matches. Attacker can't read the token from another origin.
import csrf from 'csrf'; const tokens = new csrf(); // Generate a secret per session, send token to client app.get('/csrf-token', (req, res) => { if (!req.session.csrfSecret) req.session.csrfSecret = tokens.secretSync(); res.json({ csrfToken: tokens.create(req.session.csrfSecret) }); }); // Verify on every mutating request const verifyCsrf = (req, res, next) => { const token = req.headers['x-csrf-token']; if (!tokens.verify(req.session.csrfSecret, token)) return res.status(403).json({ error: 'Invalid CSRF token' }); next(); };
Authorization: Bearer header is immune to CSRF — browsers don't automatically set custom headers on cross-site requests. This is one advantage of header-based auth over cookies.
Instant revocation — delete the Redis key and the user is logged out immediately, no waiting for token expiry.
Small request footprint — session ID cookie is ~50 bytes vs a JWT at 300–500 bytes.
Server-side control — you can inspect, modify, or invalidate any session at any time.
Simple mental model — widely understood, easy to reason about.
Stateful infrastructure — requires a shared session store (Redis) for horizontal scaling; adds an operational dependency.
CSRF exposure — cookie-based auth requires CSRF mitigation (mitigated by SameSite).
Not API-friendly — mobile apps and third-party clients struggle with cookie management.
Redis lookup per request — adds ~1–2 ms latency per authenticated request.
Interviewers want to see you articulate the "instant revocation" advantage of sessions — it's the decisive factor in choosing sessions over JWT for financial, medical, or compliance-heavy applications.
The simplest approach: set a longer maxAge on the cookie when the "remember me" checkbox is checked. Without it, the cookie is a session cookie (deleted when the browser closes).
app.post('/auth/login', async (req, res) => { const { email, password, rememberMe } = req.body; const user = await User.findOne({ email }); if (!user || !await bcrypt.compare(password, user.password)) return res.status(401).json({ error: 'Invalid credentials' }); req.session.userId = user._id; // Extend session lifetime based on "remember me" flag if (rememberMe) { req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days } else { req.session.cookie.expires = false; // session cookie — gone on browser close } res.json({ user: { id: user._id, email: user.email } }); });
Sliding expiry (also called rolling sessions) resets the session TTL on every activity — so an active user is never logged out unexpectedly, but an idle user eventually is.
express-session supports this natively with the rolling: true option, which re-sends the Set-Cookie header on every response to extend the cookie's lifetime.
app.use(session({ // ...other options... rolling: true, // reset maxAge on every response cookie: { maxAge: 30 * 60 * 1000 } // 30 min idle timeout })); // Redis TTL also needs refreshing — connect-redis handles this automatically // when the session is saved (which rolling:true triggers)
Dashboards, content editors, e-commerce carts — anywhere where an active user being suddenly logged out causes significant UX friction or data loss.
Banking, healthcare, or any application with compliance requirements (e.g. PCI-DSS mandates session timeouts). Fixed expiry forces re-authentication after a set period regardless of activity.
Secure logout has two required steps that are often implemented incompletely:
app.post('/auth/logout', (req, res) => { // Step 1: Destroy the session record in Redis req.session.destroy(err => { if (err) return res.status(500).json({ error: 'Logout failed' }); // Step 2: Clear the cookie from the browser res.clearCookie('connect.sid', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' }); res.json({ message: 'Logged out successfully' }); }); });
| Common Mistake | Why It's a Problem |
|---|---|
| Only clearing the client-side cookie | The session record still exists in Redis. Anyone who captured the cookie (via network sniff before HTTPS, or compromised device) can still use it. |
| Only destroying the Redis session | The cookie persists in the browser. If the server crashes and the session record is gone but cookie isn't cleared, it's confusing but not dangerous — however it's still incomplete. |
| Using GET /logout | CSRF — an attacker can force-logout the user with a simple image tag: <img src="https://yourapp.com/logout">. Always use POST for state-changing operations. |
| Factor | Choose Sessions | Choose JWT |
|---|---|---|
| Revocation need | Must log users out immediately (compromised account, policy violation) | Acceptable if tokens expire within minutes; can add a blocklist if needed |
| Client type | Browser-first apps — cookies are automatic and ergonomic | Mobile apps, native clients, or third-party API consumers that manage their own auth headers |
| Architecture | Monolith or small cluster sharing one Redis | Microservices — any service can verify the JWT with just a public key, no shared store needed |
| Compliance | PCI-DSS, HIPAA — regulatory mandates for audit trails, forced re-auth, session controls | Developer APIs, B2B integrations where the caller manages token lifecycle |
| Team familiarity | Traditional web teams — sessions are the older, more widely understood model | API-first teams — JWT is the de facto standard for REST and GraphQL APIs |
If an interviewer asks "which is better — sessions or JWT?" — the correct answer is "it depends on the revocation requirements, client types, and infrastructure." Giving a nuanced trade-off answer scores significantly higher than a definitive "JWT is better."
A JWT is three Base64url-encoded strings joined by dots: Header.Payload.Signature. Each part is independently decodable — but only the signature proves the token hasn't been tampered with.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header .eyJzdWIiOiI2NjQxYWJjZCIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzE2MDAwMDAwfQ ← Payload .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
| Part | Contents (decoded) | Purpose |
|---|---|---|
| Header | { "alg": "HS256", "typ": "JWT" } |
Declares the signing algorithm. Always validate this server-side — never let the client dictate the algorithm. |
| Payload | { "sub": "userId", "role": "user", "exp": 1716… } |
The claims — facts about the user and token. Not encrypted: anyone can base64-decode and read this. Never put secrets here. |
| Signature | HMACSHA256(base64url(header) + "." + base64url(payload), secret) |
Proves the token was issued by someone who knows the secret. If header or payload changes, the signature won't match and verification fails. |
// POST /auth/login app.post('/auth/login', async (req, res) => { const user = await User.findOne({ email: req.body.email }); if (!user || !await bcrypt.compare(req.body.password, user.password)) return res.status(401).json({ error: 'Invalid credentials' }); const accessToken = jwt.sign( { sub: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m', algorithm: 'HS256' } ); // Send as httpOnly cookie (safer than response body for web apps) res.cookie('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 15 * 60 * 1000 }); res.json({ user: { id: user._id, email: user.email, role: user.role } }); });
const authenticate = (req, res, next) => { try { // Support both cookie and Authorization header const token = req.cookies?.accessToken ?? req.headers.authorization?.replace('Bearer ', ''); if (!token) return res.status(401).json({ error: 'No token' }); req.user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] // always whitelist — prevents none attack }); next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); } }; // Usage router.get('/profile', authenticate, (req, res) => res.json(req.user));
jwt.verify(), attaches decoded payload to req.userTokenExpiredError, React intercepts the 401 and calls POST /auth/refresh| Storage | XSS Risk | CSRF Risk | Persists on Refresh | Verdict |
|---|---|---|---|---|
| localStorage | 🔴 High — JS can read it; any injected script steals token | 🟢 None — not auto-sent by browser | Yes | ❌ Avoid for auth tokens |
| sessionStorage | 🔴 High — same as localStorage | 🟢 None | No (tab close) | ❌ Avoid |
| JS memory (variable) | 🟢 None — script injection can't access closure variables | 🟢 None — sent via header | No (page refresh) | 🟡 Good for access token; needs refresh strategy |
| httpOnly Cookie | 🟢 None — JS cannot read it | 🟡 Mitigated by SameSite=Lax | Yes (with Max-Age) | ✅ Best for web apps |
POST /auth/refresh to get a new access token from the refresh cookie. This gives you XSS protection on the long-lived token and no CSRF risk on the short-lived one.
// tokenStore.js — module-level variable, invisible to injected scripts let accessToken = null; export const setToken = t => accessToken = t; export const getToken = () => accessToken; export const clearToken = () => accessToken = null;
A single long-lived JWT (e.g. 30-day expiry) creates a large window of exposure: if it's stolen, the attacker has access for up to 30 days and you can't revoke it without a blocklist. The access + refresh pattern solves this with two cooperating tokens:
Short-lived (15 min). Sent with every API request. Stateless — no DB lookup needed. Small blast radius: stolen token expires soon on its own.
Long-lived (7–30 days). Stored in httpOnly cookie. Only sent to POST /auth/refresh. Used solely to mint new access tokens. Can be stored in DB for revocation capability.
POST /auth/refresh — browser auto-sends the refresh cookieThe access token is a day pass to an office — cheap to issue, expires quickly, low risk if lost. The refresh token is the master key — stored safely, rarely used, only to get new day passes.
Refresh token rotation means: every time a refresh token is used to get a new access token, the old refresh token is invalidated and a brand-new one is issued. This limits the lifetime of any single refresh token and enables theft detection.
app.post('/auth/refresh', async (req, res) => { const incomingToken = req.cookies?.refreshToken; if (!incomingToken) return res.status(401).json({ error: 'No refresh token' }); // Look up the token in DB (stores hashed version) const storedToken = await RefreshToken.findOne({ tokenHash: hash(incomingToken) }); if (!storedToken) { // Token not found — either expired or already used // If it was used before, it may be a replay attack → revoke entire family const family = await RefreshToken.findOne({ tokenHash: hash(incomingToken), used: true }); if (family) await RefreshToken.deleteMany({ familyId: family.familyId }); return res.status(401).json({ error: 'Token reuse detected' }); } // Mark old token as used await RefreshToken.findByIdAndUpdate(storedToken._id, { used: true }); // Issue new access token + new refresh token (same family) const newAccessToken = jwt.sign({ sub: storedToken.userId }, process.env.JWT_SECRET, { expiresIn: '15m' }); const newRefreshToken = generateSecureToken(); // crypto.randomBytes(64).toString('hex') await RefreshToken.create({ tokenHash: hash(newRefreshToken), userId: storedToken.userId, familyId: storedToken.familyId, // same family for theft detection expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }); res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'lax' }); res.json({ accessToken: newAccessToken }); });
familyId. If an already-used token is presented again, it means a copy was stolen and replayed. The correct response is to revoke every token in that family — logging out all devices for that session.
This is the core trade-off of stateless JWTs — you can't truly "delete" a token the way you delete a session. But there are three practical strategies:
| Strategy | How It Works | Trade-off |
|---|---|---|
| Short Expiry | Access tokens expire in 15 min. Accept eventual consistency — revocation takes effect when the token expires. | Simplest. Acceptable for most apps. Not suitable when you need instant revocation (e.g. compromised account). |
| Redis Blocklist | On logout/revoke, store the JWT's jti (or full token hash) in Redis with a TTL matching the token's remaining lifetime. Middleware checks blocklist on every request. | Adds one Redis lookup per request. Effective and fast, but re-introduces some statefulness. |
| Token Version per User | Store a tokenVersion integer on the User document. Embed it in every JWT. On revoke, increment the version. Middleware compares token version vs DB version — mismatch = rejected. | One DB read per request. But revokes all tokens for a user at once — useful for "logout all devices." |
const authenticate = async (req, res, next) => { try { const token = req.cookies?.accessToken; const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); // Check blocklist using jti claim const isBlocked = await redis.get(`blocklist:${decoded.jti}`); if (isBlocked) return res.status(401).json({ error: 'Token revoked' }); req.user = decoded; next(); } catch { res.status(401).json({ error: 'Invalid token' }); } }; // On logout: add jti to blocklist with TTL = remaining token lifetime const ttl = decoded.exp - Math.floor(Date.now() / 1000); await redis.setEx(`blocklist:${decoded.jti}`, ttl, '1');
Stateless — no session store needed; any server instance can verify the token with just the secret or public key.
Works across services — a single token can authenticate across multiple microservices without shared infrastructure.
Carries claims — role, permissions, and user metadata travel with the token; no extra DB lookup for common data.
Cross-domain friendly — works naturally with mobile apps, native clients, and third-party API consumers.
Compact — sent as a URL-safe string; trivial to include in headers, cookies, or query params.
Revocation is hard — once issued, a token is valid until expiry unless you add a blocklist (which adds statefulness back).
Payload is readable — base64 is not encryption; don't put PII or secrets in the payload.
Larger than session IDs — a JWT with several claims can be 400–600 bytes; adds bandwidth on every request.
Secret rotation is disruptive — changing JWT_SECRET immediately invalidates every active token, logging out all users.
Stale claims — if a user's role changes, the JWT still carries the old role until it expires.
The "stale claims" problem is often overlooked. If you embed role: 'user' in a 24-hour JWT and the admin upgrades that user's role, the JWT still says 'user' for up to 24 hours. Mitigation: keep access tokens short (15 min) or do a DB lookup for critical permission checks.
The alg: "none" attack exploits JWT libraries that trust the algorithm declared in the header of the token itself. An attacker crafts a token with the header { "alg": "none" } and an empty signature. Vulnerable libraries skip signature verification entirely because the algorithm says "no signature needed."
// Header (base64url-decoded) { "alg": "none", "typ": "JWT" } // Payload — attacker puts whatever they want { "sub": "admin-user-id", "role": "admin", "exp": 9999999999 } // Signature — empty string "" // Resulting token: header.payload. (trailing dot, no signature) // Vulnerable server verifies with alg:none → accepts it as valid!
// ❌ Vulnerable — trusts whatever alg the token header says jwt.verify(token, secret); // ✅ Safe — explicitly whitelist the algorithm jwt.verify(token, secret, { algorithms: ['HS256'] }); // ✅ Also safe for RS256 jwt.verify(token, publicKey, { algorithms: ['RS256'] });
jsonwebtoken library. It was patched, but the underlying risk exists in any implementation that reads the algorithm from the token header. The algorithms option in jsonwebtoken is your primary defence — always set it explicitly.
sub (user ID / MongoDB ObjectId)
role / permissions (non-secret, needed often)
iss, aud, exp, iat (standard claims)
tenantId (for multi-tenant apps)
sessionId (to link to a session if needed)
Passwords or hashes
Credit card or financial data
SSN, national ID, DOB
API secrets or private keys
Full email/phone (use sub as identifier instead)
Anything that should stay confidential
The payload is Base64url-encoded, not encrypted. Anyone who receives the token can decode and read the payload instantly — there's no computational barrier. If you need an encrypted token, use JWE (JSON Web Encryption) instead of plain JWT.
// ✅ Minimal — only what downstream services need for auth decisions const payload = { sub: user._id.toString(), // identify the user role: user.role, // needed for authorization iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID() // unique ID for revocation }; // ❌ Over-stuffed — exposes sensitive data const badPayload = { sub: user._id, email: user.email, phone: user.phone, address: user.address, dob: user.dob };
The goal: when an access token expires mid-session, automatically refresh it and retry the failed request — the user never sees an error or a redirect to login.
import axios from 'axios'; import { getToken, setToken, clearToken } from './tokenStore'; let refreshPromise = null; // deduplication — only one refresh at a time api.interceptors.request.use(config => { const token = getToken(); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); api.interceptors.response.use( res => res, async err => { const original = err.config; if (err.response?.status === 401 && !original._retry) { original._retry = true; try { // Multiple concurrent 401s → only one refresh call if (!refreshPromise) { refreshPromise = api.post('/auth/refresh', {}, { withCredentials: true }) .finally(() => refreshPromise = null); } const { data } = await refreshPromise; setToken(data.accessToken); // store new token in memory return api(original); // retry original request } catch { clearToken(); window.location.assign('/login'); // refresh failed — re-login } } return Promise.reject(err); } );
refreshPromise guard, five simultaneous API calls that all get a 401 will each trigger a refresh request, causing a race condition where four of the five refresh tokens are immediately invalidated by rotation — logging the user out.
OAuth 2.0 is an authorization framework — not an authentication protocol. It solves the password anti-pattern: before OAuth, if you wanted a third-party app to access your Gmail, you had to give it your Gmail password. OAuth replaces this with scoped, time-limited access tokens that can be revoked without changing your password.
OAuth is like giving a valet a parking ticket instead of your car keys. The valet (third-party app) can park the car (access your data), but they can't open the boot (full account access). You can cancel the ticket (revoke the token) without getting new keys (changing your password).
| Aspect | OAuth 2.0 | OIDC (built on OAuth) |
|---|---|---|
| Purpose | Authorization — grants an app access to a resource on behalf of a user | Authentication — proves who the user is via an id_token |
| Output | access_token (opaque or JWT) for calling APIs | id_token (always a JWT) with user identity claims |
| Question answered | "Can this app read your Google Calendar?" | "Who is this person logging in?" |
id_token.
https://accounts.google.com/o/oauth2/auth?client_id=…&redirect_uri=…&scope=openid email&state=RANDOM&response_type=coderedirect_uri with a short-lived code and the state parameter: /auth/google/callback?code=4/…&state=RANDOMstate matches what it sent. This prevents CSRF on the callback.code to Google's token endpoint along with client_secret (never exposed to browser). Google returns access_token, id_token, and optionally a refresh_token.id_token, find or create the user in MongoDB, then issue your own session cookie or JWT. The Google tokens stay server-side.Tokens exchanged via back-channel (server-to-server POST). The code in the URL is short-lived and useless without the client_secret. Tokens never appear in browser history or logs.
Tokens returned directly in the URL fragment (#access_token=…). Visible in browser history, referrer headers, and proxy logs. No back-channel, so no client_secret verification. Deprecated by RFC 9700.
PKCE (Proof Key for Code Exchange, pronounced "pixy") solves a fundamental problem: SPAs and mobile apps are public clients — they can't securely store a client_secret. Anyone can reverse-engineer a React bundle or APK to find the secret. PKCE replaces the secret with a cryptographic challenge.
code_verifier = crypto.randomBytes(64).toString('base64url')code_challenge = SHA256(code_verifier) then base64url-encodes itcode_challenge + code_challenge_method=S256 in the authorization URL. Auth server stores the challenge.code_verifier with the code. Auth server hashes it and checks it matches the stored challenge. No verifier, no token.// Step 1 & 2 — generate verifier + challenge const verifier = crypto.randomBytes(64).toString('base64url'); const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); sessionStorage.setItem('pkce_verifier', verifier); // store for callback // Step 3 — build authorization URL const authUrl = new URL('https://accounts.google.com/o/oauth2/auth'); authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('code_challenge', challenge); authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('response_type', 'code'); window.location.href = authUrl.toString(); // Step 4 — token exchange in callback const verifier = sessionStorage.getItem('pkce_verifier'); await fetch('/auth/google/token', { method: 'POST', body: JSON.stringify({ code, code_verifier: verifier }) });
OAuth 2.0 tells you what a user can access. OIDC tells you who the user is. OIDC is a thin identity layer on top of OAuth 2.0 that adds three things:
scope=openid to an OAuth request tells the auth server to return an id_token. Additional scopes: profile, email, address./.well-known/openid-configuration — a JSON document advertising the IdP's endpoints, supported scopes, algorithms, and JWKS URI. Enables automatic client configuration.import { OAuth2Client } from 'google-auth-library'; const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); async function verifyGoogleIdToken(idToken) { const ticket = await client.verifyIdToken({ idToken, audience: process.env.GOOGLE_CLIENT_ID // verify aud claim }); const { sub, email, email_verified, name, picture } = ticket.getPayload(); // Only trust verified emails if (!email_verified) throw new Error('Email not verified by Google'); return { googleId: sub, email, name, picture }; }
In a MERN context, the typical pattern is: verify the id_token server-side → extract sub (Google's stable user ID) + email → find or create a User in MongoDB → issue your own JWT/session for subsequent requests.
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: '/auth/google/callback', scope: ['openid', 'email', 'profile'] }, async (accessToken, refreshToken, profile, done) => { try { // Upsert user by Google ID let user = await User.findOne({ googleId: profile.id }); if (!user) { user = await User.create({ googleId: profile.id, email: profile.emails[0].value, name: profile.displayName, avatar: profile.photos[0]?.value }); } done(null, user); } catch (err) { done(err); } }));
// Initiate OAuth flow router.get('/auth/google', passport.authenticate('google', { scope: ['openid', 'email', 'profile'] }) ); // Handle callback from Google router.get('/auth/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login' }), (req, res) => { // req.user is the Mongoose user from the strategy callback const accessToken = jwt.sign( { sub: req.user._id, role: req.user.role }, process.env.JWT_SECRET, { expiresIn: '15m' } ); res.cookie('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'lax' }); res.redirect('http://localhost:3000/dashboard'); // redirect to React app } );
session: false tells Passport not to use its own session serialisation (which requires passport.serializeUser). Since we're issuing our own JWT, we don't need Passport's session layer — keep it clean.
| Role | Definition | MERN Example |
|---|---|---|
| Resource Owner | The user who owns the data and grants access to it. | The person clicking "Sign in with Google" on your MERN app. |
| Client | The application requesting access to the resource on behalf of the user. | Your MERN app — both the Express backend (confidential client) and the React frontend (public client). |
| Authorization Server | Issues access tokens after successfully authenticating the user and obtaining consent. | Google's OAuth server (accounts.google.com). Could also be your own Auth0, Keycloak, or custom OAuth server. |
| Resource Server | Hosts the protected resources. Validates access tokens and serves data. | Google's APIs (Gmail, Calendar, People API) — or your own Express API when it validates JWTs issued by your auth service. |
You (Resource Owner) hand a valet ticket (access token) issued by a hotel front desk (Authorization Server) to a valet company (Client), allowing them to move your car (Resource) from the hotel garage (Resource Server) — without ever giving them your car keys (your Google password).
state parameter and what attack does it prevent?The state parameter is a random, unguessable string your app generates before redirecting the user to the authorization server. After the user authenticates, the authorization server echoes it back in the redirect. Your app checks the returned state matches the one it sent.
Without it, an attacker can perform a CSRF on the OAuth callback:
code but doesn't complete it — stopping just before the callback redirect.code and tricks the victim into clicking it (e.g. via CSRF in an image/link).// Before redirect — store state in session app.get('/auth/google', (req, res) => { const state = crypto.randomBytes(16).toString('hex'); req.session.oauthState = state; const url = `https://accounts.google.com/o/oauth2/auth?` + new URLSearchParams({ client_id, redirect_uri, state: state, scope: 'openid email', response_type: 'code' }); res.redirect(url); }); // In callback — verify state before doing anything app.get('/auth/google/callback', (req, res) => { if (req.query.state !== req.session.oauthState) return res.status(403).json({ error: 'State mismatch — possible CSRF' }); delete req.session.oauthState; // consume it — single use // proceed with token exchange... });
Passport.js handles state generation and verification automatically when you use passport.authenticate('google', { state: true }).
Account linking merges multiple OAuth identities into a single user record. The approach depends on whether the user is already authenticated when they add a new provider:
Check if email already exists in DB. If yes → link the new providerId to the existing account (but only if email is verified by the provider). If no → create a new account.
User is already authenticated. The OAuth callback has access to req.user. Simply add the new { provider: 'github', providerId: '...' } to their existing document.
const userSchema = new Schema({ email: { type: String, unique: true, sparse: true }, password: String, // null for OAuth-only users providers: [{ provider: { type: String, enum: ['google', 'github', 'facebook'] }, providerId: String, // stable ID from the OAuth provider linkedAt: { type: Date, default: Date.now } }] }); // Find user by provider ID or verified email const findOrCreateOAuthUser = async ({ provider, providerId, email, emailVerified }) => { // 1. Look up by provider ID first (most reliable) let user = await User.findOne({ 'providers.providerId': providerId }); if (user) return user; // 2. Link to existing account by verified email if (emailVerified && email) { user = await User.findOne({ email }); if (user) { user.providers.push({ provider, providerId }); return user.save(); } } // 3. Create new account return User.create({ email: emailVerified ? email : undefined, providers: [{ provider, providerId }] }); };
email_verified: true before linking by email.
| Pitfall | Attack | Prevention |
|---|---|---|
| Missing state param | CSRF on callback — attacker links victim's account to their OAuth identity | Always generate and verify a random state value stored in session |
| Open redirect on redirect_uri | Attacker appends &redirect_uri=attacker.com; auth server sends code there |
Register exact redirect URIs with the provider. Never use wildcards. Validate server-side. |
| Trusting unverified email | Attacker creates OAuth account with victim's unverified email → auto-linked | Only link/create accounts on email_verified: true from the provider |
Not verifying aud claim |
id_token issued to another Google app is accepted by your server | Always verify aud === your_client_id when validating id_tokens |
| Implicit flow usage | Tokens in URL fragment visible in browser history and logs | Use Authorization Code + PKCE. Never use Implicit flow. |
| Storing access tokens insecurely | Google access tokens in localStorage stolen via XSS | Exchange Google's tokens server-side immediately. Store your own short-lived JWT in httpOnly cookie. |
| Leaking client_secret | Secret bundled in frontend code, exposed in source maps | Token exchange always happens server-side. Client_secret never touches the browser. |
✅ You want social login (Google, GitHub, Microsoft) — critical for consumer apps to reduce signup friction.
✅ Enterprise clients need SSO with their existing IdP (Okta, Azure AD, Google Workspace).
✅ You're building a developer-facing API and need delegated access (like GitHub Apps).
✅ You want to offload auth complexity to a managed service (Auth0, Clerk, Supabase Auth).
✅ Your team lacks security expertise to build auth safely — get-auth-wrong and it's catastrophic.
✅ Regulatory requirements prohibit data leaving your infrastructure (healthcare, government, finance).
✅ You need full control over the auth flow, MFA policies, session management, and audit logs.
✅ You're in a highly air-gapped or offline environment where external IdPs aren't reachable.
✅ Third-party IdP costs or vendor lock-in are unacceptable at scale.
⚠️ If you choose custom auth, use a proven library (Lucia, Better Auth) rather than building from scratch.
The real-world answer for most MERN apps is a hybrid: custom email/password auth + OAuth social login, with an optional Auth0/Clerk layer if the team doesn't want to maintain the auth infrastructure. Frame your answer around the team's security expertise and the app's compliance requirements.
API key authentication is a simple scheme where a client sends a shared secret with each request. Unlike JWT, API keys have no expiry baked in and carry no user claims — they identify the calling application, not a human user. Best suited for server-to-server communication and developer-facing APIs.
import crypto from 'crypto'; // Generate a secure, URL-safe API key const generateApiKey = () => { const raw = crypto.randomBytes(32).toString('base64url'); return `sk_live_${raw}`; // prefix helps users identify key type }; // NEVER store the raw key — store a SHA-256 hash const hashKey = key => crypto.createHash('sha256').update(key).digest('hex'); // On key creation — show raw key ONCE, store hash const createApiKey = async (userId) => { const rawKey = generateApiKey(); await ApiKey.create({ keyHash: hashKey(rawKey), prefix: rawKey.slice(0, 12), // store prefix for display ("sk_live_ab12…") userId, scopes: ['read'], createdAt: new Date() }); return rawKey; // return raw key to user — this is the only time it's visible };
const validateApiKey = async (req, res, next) => { const key = req.headers['x-api-key'] ?? req.headers.authorization?.replace('Bearer ', ''); if (!key) return res.status(401).json({ error: 'API key required' }); const apiKey = await ApiKey.findOne({ keyHash: hashKey(key), revokedAt: null }); if (!apiKey) return res.status(401).json({ error: 'Invalid API key' }); req.apiKey = apiKey; next(); };
| Security Rule | Why |
|---|---|
| Store hash only (SHA-256) | If DB is breached, attacker gets useless hashes — same principle as passwords |
| Show raw key only once | Like GitHub PATs — display on creation, user must copy it. You can't recover it later. |
Prefix keys (sk_live_) | Users can visually identify them; secret scanning tools (GitHub) can detect accidental commits |
| Scope keys (read/write/admin) | Principle of least privilege — a webhook key shouldn't have admin access |
| Support rotation | Allow creating a new key before revoking the old one to enable zero-downtime rotation |
Magic links authenticate a user by emailing them a one-time, time-limited link. Clicking it proves they control the inbox — no password required. The UX is excellent: no password to forget, no phishing risk from a login form, and it works on any device.
POST /auth/magic-link { email }. Always respond with a generic message regardless of whether the email exists (prevent user enumeration).crypto.randomBytes(32).toString('hex'). Hash it with SHA-256. Store the hash in MongoDB alongside the userId and a 15-minute expiry.https://yourapp.com/auth/verify?token=RAW&email=user@…GET /auth/verify?token=…&email=… → hash the incoming token, look up the DB record, check expiry.app.get('/auth/verify', async (req, res) => { const { token, email } = req.query; if (!token || !email) return res.status(400).send('Invalid link'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const record = await MagicToken.findOne({ tokenHash: tokenHash, email, expiresAt: { $gt: new Date() } // not expired }); if (!record) return res.status(401).send('Link expired or already used'); await MagicToken.deleteOne({ _id: record._id }); // single-use const accessToken = jwt.sign({ sub: record.userId }, process.env.JWT_SECRET, { expiresIn: '15m' }); res.cookie('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'lax' }); res.redirect('/dashboard'); });
WebAuthn (Web Authentication API) is a W3C standard that lets websites use strong public-key cryptography for authentication — via biometrics (Face ID, Touch ID), device PINs, or hardware security keys (YubiKey). Passkeys are WebAuthn credentials that sync across a user's devices via the OS (iCloud Keychain, Google Password Manager).
The keypair is scoped to the exact origin (domain + protocol). A phishing site at g00gle.com requesting a Google passkey will fail — the browser refuses because the origin doesn't match where the passkey was registered. There's nothing to trick the user into typing.
The @simplewebauthn/server and @simplewebauthn/browser packages handle the cryptographic complexity. They abstract challenge generation, response verification, and credential storage patterns.
TOTP (Time-based One-Time Password, RFC 6238) generates a 6-digit code that changes every 30 seconds. Both your server and the user's authenticator app (Google Authenticator, Authy) know the same shared secret and independently compute HOTP(secret, floor(currentTime / 30)) — they always arrive at the same number without any network call.
import speakeasy from 'speakeasy'; import QRCode from 'qrcode'; // Step 1: Generate a secret for the user during 2FA setup app.post('/auth/2fa/setup', authenticate, async (req, res) => { const secret = speakeasy..generateSecret({ name: `YourApp (${req.user.email})`, length: 20 }); // Store PENDING secret (not active until user confirms first code) await User.findByIdAndUpdate(req.user.sub, { twoFactorSecretPending: secret.base32 // encrypt this at rest }); const qrDataUrl = await QRCode.toDataURL(secret.otpauth_url); res.json({ qrCode: qrDataUrl, manualKey: secret.base32 }); }); // Step 2: User scans QR code, enters first TOTP to confirm setup app.post('/auth/2fa/verify-setup', authenticate, async (req, res) => { const user = await User.findById(req.user.sub); const valid = speakeasy.totp.verify({ secret: user.twoFactorSecretPending, encoding: 'base32', token: req.body.code, window: 1 // accept ±1 time step for clock skew }); if (!valid) return res.status(400).json({ error: 'Invalid code' }); // Promote pending secret to active, generate backup codes const backupCodes = Array.from({ length: 8 }, () => crypto.randomBytes(4).toString('hex') ); await User.findByIdAndUpdate(req.user.sub, { twoFactorSecret: user.twoFactorSecretPending, twoFactorEnabled: true, twoFactorBackupCodes: backupCodes.map(hashKey), $unset: { twoFactorSecretPending: '' } }); res.json({ backupCodes }); // show once — user must save these });
HTTP Basic Authentication sends credentials as a Base64-encoded username:password string in the Authorization header on every request:
Authorization: Basic dXNlcjpwYXNzd29yZA==
// Base64("user:password") — trivially reversible, NOT encryption
| Scenario | Appropriate? | Why |
|---|---|---|
| Internal admin tools over HTTPS | ✅ Acceptable | Simple, no token management. TLS protects the credentials in transit. |
| Server-to-server API calls | ✅ Acceptable | Both sides are controlled environments. Works fine with HTTPS. |
| Webhook receiver verification | ✅ Common | GitHub webhooks support Basic Auth for the receiver endpoint. |
| Public-facing user auth | ❌ Never | Credentials sent on every request — larger attack surface. No logout mechanism. No token rotation. |
| Without HTTPS | ❌ Never | Base64 is trivially decoded — credentials are visible in plaintext to any MITM observer. |
basic-auth package: const credentials = auth(req); if (!credentials || credentials.name !== 'admin') return res.status(401).set('WWW-Authenticate', 'Basic').end(); — straightforward for internal tooling.
| Situation | Best Choice | Reason |
|---|---|---|
| Server A calls Server B — no user context needed | API Key | Simplest. Identifies the calling service, not a user. No user session involved. |
| Server A calls Server B on behalf of User X | JWT (service-to-service) | JWT carries user identity claims across the service boundary without an extra lookup. |
| Browser SPA authenticating a user | JWT in httpOnly cookie | Stateless, XSS-safe storage, works across page refreshes with refresh token pattern. |
| Mobile app authenticating a user | JWT in secure storage | No cookie jar on mobile — JWT stored in Keychain (iOS) / Keystore (Android). |
| Your app accesses another company's API as the user | OAuth 2.0 | Delegated access — user grants consent, you get a scoped token, user can revoke it. |
| Third-party developer integrating your API | API Key + optional OAuth | API keys for simple integrations; OAuth when the developer needs to act as specific users. |
| Webhook delivery verification | HMAC signature | Sign the payload with a shared secret — receiver verifies authenticity without exchanging tokens. |
The key differentiator is user context. API keys represent a system identity. JWTs carry user identity. OAuth delegates a user's permissions to a third party. Articulate this clearly and the interviewer knows you understand the design intent of each mechanism.
Standard TLS authenticates the server to the client (via the server's certificate). Mutual TLS (mTLS) requires both sides to present and verify certificates — so the server also authenticates the client before completing the handshake.
Client verifies server's certificate (issued by trusted CA). Server trusts all clients — authentication happens at the application layer (API key, JWT).
Server also requires the client to present a certificate. Only clients with a certificate signed by your internal CA can connect — rogue services are rejected at the TLS layer before any HTTP exchange.
- Zero-trust microservice mesh — each service has its own certificate; services only accept connections from other certified services in the same cluster (Istio, Linkerd handle this automatically)
- High-security APIs — banking, payment processors, healthcare data APIs where network-layer identity verification is a compliance requirement
- Webhook security — guarantee that the incoming request is from a specific third party (not just signed, but TLS-authenticated at the transport layer)
import https from 'https'; import fs from 'fs'; const server = https.createServer({ key: fs.readFileSync('server-key.pem'), cert: fs.readFileSync('server-cert.pem'), ca: fs.readFileSync('ca-cert.pem'), // internal CA that signs client certs requestCert: true, // require client certificate rejectUnauthorized: true // reject if cert isn't signed by our CA }, app); // Access client cert info in middleware app.use((req, res, next) => { const cert = req.socket.getPeerCertificate(); req.clientService = cert.subject.CN; // e.g. "payment-service" next(); });
const raw = crypto.randomBytes(32).toString('hex'). Store only the SHA-256 hash in MongoDB with a 1-hour expiry and the userId./reset-password?token=RAW. Invalidate any existing reset tokens for this user first (only one active at a time).| Mistake | Consequence |
|---|---|
| Storing raw token in DB | DB breach → attacker resets any account they want |
| Token expiry > 1 hour | Wide window for an attacker who intercepts the email |
| Not revoking old sessions post-reset | Attacker who had access still has a valid session cookie or JWT after the password changes |
| Confirming whether email exists | Enables user enumeration — attackers can harvest valid emails from your system |
| Reusable tokens | Attacker replays the link multiple times; token should be deleted immediately on first use |
No passwords to breach — eliminates the most common attack vector; credential stuffing becomes impossible.
Better UX — no password to remember, reset, or fail complexity rules. Particularly good for infrequent-use tools.
Phishing-resistant (passkeys) — domain-scoped keypairs can't be tricked onto fake sites.
Reduces support load — "forgot password" is one of the highest-volume support ticket types; passwordless eliminates it.
Email dependency (magic links) — your auth security is only as strong as the user's email account security.
Device dependency (passkeys) — losing a device without a sync backup can lock the user out.
Adoption friction (passkeys) — users unfamiliar with passkeys may be confused; fallback to email/SMS is still needed.
Offline issues — magic links require email delivery; TOTP works offline but needs the authenticator app.
- Magic links — internal tools, low-frequency apps, when UX simplicity outweighs email dependency risk
- Passkeys — consumer apps targeting modern devices (iOS 16+, Android 9+, Chrome 108+) where phishing-resistance is a priority
- TOTP as second factor — not passwordless alone, but dramatically reduces breach risk when layered on passwords
- Not suitable — highly offline environments, or when your user base has very low tech literacy with biometrics
SMS OTP sends a 6-digit one-time code to a registered phone number. The user enters the code to verify possession of the phone. While better than no 2FA, it has fundamental weaknesses that make it unacceptable as a sole auth factor for high-security applications.
| Attack | How It Works | Real-World Impact |
|---|---|---|
| SIM Swapping | Attacker calls the carrier, impersonates the victim (using personal data from social media / data breaches), convinces them to transfer the number to a new SIM. | Jack Dorsey's Twitter account was hijacked via SIM swap. Crypto theft in the millions via SIM swap attacks. |
| SS7 Vulnerabilities | The Signaling System No. 7 protocol that routes SMS globally has known vulnerabilities allowing nation-state actors and sophisticated criminals to intercept messages. | Demonstrated by researchers to intercept codes from banking apps and bypass 2FA. |
| Real-time Phishing | Attacker's phishing site relays the OTP in real time — user enters code on fake site, attacker immediately uses it on the real site within the validity window. | Undetectable to the user; completely bypasses SMS 2FA. |
| Malware | Android malware intercepts SMS messages and forwards OTPs to the attacker silently. | Banking trojans routinely intercept mobile SMS OTPs. |
SMS OTP is still better than nothing — for low-value consumer apps it's an acceptable second factor. The key point is knowing why it's weak and what to use instead (TOTP, passkeys) for anything security-sensitive.
Never store plaintext passwords. Always use a slow, salted, adaptive hashing algorithm designed to resist brute-force and GPU cracking.
| Property | bcrypt | argon2 |
|---|---|---|
| Algorithm type | Blowfish-based KDF | Memory-hard KDF (PHC 2015 winner) |
| Cost parameter | work factor (rounds = 2ⁿ) | time, memory, parallelism |
| GPU resistance | Good (sequential) | Excellent (memory-hard) |
| Max password length | 72 bytes — silently truncates | No limit |
| Node package | bcryptjs / bcrypt | argon2 |
| Production pick | Widely used, safe default | Preferred for new systems |
const bcrypt = require('bcryptjs'); const SALT_ROUNDS = 12; // 10 minimum; 12–14 for high-security apps const hashPassword = (p) => bcrypt.hash(p, SALT_ROUNDS); const verifyPassword = (p, h) => bcrypt.compare(p, h); // Mongoose pre-save hook userSchema.pre('save', async function(next) { if (!this.isModified('password')) return next(); this.password = await hashPassword(this.password); next(); }); userSchema.methods.comparePassword = function(candidate) { return bcrypt.compare(candidate, this.password); };
const argon2 = require('argon2'); const hash = await argon2.hash(plaintext, { type: argon2.argon2id, // hybrid, recommended memoryCost: 2 ** 16, // 64 MB timeCost: 3, parallelism: 1, }); const valid = await argon2.verify(hash, plaintext);
- Never use MD5, SHA-1, SHA-256 for passwords — they are too fast
- Never implement your own salt — bcrypt/argon2 handle it internally
- Store only the hash string — it embeds algorithm, salt, and cost
- bcrypt silently truncates at 72 bytes — use argon2 for long passphrases
- Tune cost so hashing takes ~100–300 ms on your hardware
Rate limiting caps how many requests a client can make in a time window. For auth endpoints it is the primary defense against automated brute-force and credential-stuffing.
const rateLimit = require('express-rate-limit'); const RedisStore = require('rate-limit-redis'); const { createClient } = require('redis'); const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }), keyGenerator: (req) => req.body?.email ? `login:${req.body.email.toLowerCase()}` // key by email (account-level) : req.ip, skipSuccessfulRequests: true, handler: (req, res) => res.status(429).json({ error: 'Too many login attempts. Try again in 15 minutes.', }), }); app.use('/api/', rateLimit({ windowMs: 60000, max: 100, store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }) })); app.post('/api/auth/login', loginLimiter, loginController); app.post('/api/auth/forgot-password', rateLimit({ windowMs: 3600000, max: 5 }), forgotController);
| Strategy | Key | Protects against |
|---|---|---|
| IP-based | req.ip | Single-source brute force |
| Account-based | email | Distributed attacks targeting one account |
| Combined | ip:email | Both simultaneously |
Set app.set('trust proxy', 1) so Express reads the real client IP from X-Forwarded-For — otherwise all clients share one bucket and everyone gets locked out simultaneously.
helmet is a collection of 15 Express middleware functions that set security-related HTTP response headers. One app.use(helmet()) call covers most of them with sensible defaults.
const helmet = require('helmet'); app.use(helmet()); // sensible defaults for all 15 headers app.use(helmet.contentSecurityPolicy({ directives: { "'self'", "https://cdn.jsdelivr.net"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'"], objectSrc: ["'none'"], upgradeInsecureRequests: [], }, }));
| Header | Purpose | Helmet default |
|---|---|---|
Content-Security-Policy | Allowlist for scripts, styles, iframes — primary XSS mitigation | Conservative policy |
Strict-Transport-Security | Forces HTTPS for all future requests (HSTS) | max-age=15552000 |
X-Content-Type-Options | Prevents MIME-type sniffing | nosniff |
X-Frame-Options | Blocks clickjacking via iframes | SAMEORIGIN |
X-XSS-Protection | Legacy browser XSS filter | 0 (CSP is better) |
Referrer-Policy | Controls referrer info sent to other origins | no-referrer |
Cross-Origin-Opener-Policy | Isolates browsing context — prevents Spectre attacks | same-origin |
Helmet removes the X-Powered-By: Express header by default. Leaving it exposes your framework version, making targeted exploits easier.
HTTPS enforcement is layered. Handle TLS termination at the load balancer, redirect HTTP→HTTPS at the server, and use HSTS so browsers never try HTTP again after the first secure visit.
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header Strict-Transport-Security
"max-age=31536000; includeSubDomains; preload" always;
}
// Required when behind Nginx/ALB app.set('trust proxy', 1); // Redirect HTTP → HTTPS app.use((req, res, next) => { if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next(); res.redirect(301, `https://${req.headers.host}${req.url}`); }); // HSTS — browser never tries HTTP again for 1 year app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true, preload: true }));
| Technique | Prevents | Notes |
|---|---|---|
| HTTP → HTTPS redirect | Accidental cleartext | First visit still vulnerable; HSTS fixes that |
| HSTS max-age | Downgrade after first HTTPS visit | Cached in browser; 1 year standard |
| HSTS preload | Even first visit downgrade | Submit at hstspreload.org |
| TLSv1.3 only | POODLE, BEAST on old TLS | Set in Nginx / load balancer config |
Secure cookie flag | Cookie leaked over HTTP | Always set for auth cookies |
XSS lets attackers inject scripts to steal localStorage tokens, hijack sessions, or deface UI. Defense is multi-layered — no single control is sufficient.
// SAFE — React escapes HTML entities automatically const name = "<script>alert(1)</script>"; return <div>{name}</div>; // renders as text, not a live script // DANGEROUS — bypasses React's escaping; only use with sanitized content return <div dangerouslySetInnerHTML={{ __html: userContent }} />;
// Client — sanitize before rendering rich HTML import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['b', 'i', 'a', 'p'], ALLOWED_ATTR: ['href'], FORBID_ATTR: ['onclick', 'onerror'], }); return <div dangerouslySetInnerHTML={{ __html: clean }} />; // Server — strip HTML from all plain-text body fields const sanitizeHtml = require('sanitize-html'); app.use((req, res, next) => { if (req.body) { for (const key of Object.keys(req.body)) { if (typeof req.body[key] === 'string') req.body[key] = sanitizeHtml(req.body[key], { allowedTags: [], allowedAttributes: {} }); } } next(); });
CSP → HttpOnly cookies → React escaping → DOMPurify → server sanitization. Storing auth tokens in localStorage makes them trivially stealable if any XSS exists — use HttpOnly cookies so JavaScript cannot read them at all.
Account lockout temporarily blocks a user after repeated failed logins. Progressive delays make each retry more expensive without full lockout — better UX for legitimate users while deterring bots.
async function login(req, res) { const { email, password } = req.body; const key = `login_fail:${email.toLowerCase()}`; const failures = Number(await redis.get(key)) || 0; if (failures >= 10) return res.status(429).json({ error: 'Account temporarily locked. Try again in 15 minutes.' }); const user = await User.findOne({ email }); const valid = user && await user.comparePassword(password); if (!valid) { const count = await redis.incr(key); if (count === 1) await redis.expire(key, 15 * 60); // Exponential delay: 0 / 0 / 0 / 500ms / 1s / 2s / 4s / 8s / 16s… const delay = count > 3 ? Math.min(500 * 2 ** (count - 4), 16_000) : 0; await new Promise(r => setTimeout(r, delay)); return res.status(401).json({ error: 'Invalid credentials' }); } await redis.del(key); // Success — reset counter }
| Failures | Action | Rationale |
|---|---|---|
| 1–3 | Immediate error response | Typos — no friction for legitimate users |
| 4–6 | 500 ms → 2 s delay | Slows bots, barely noticeable to humans |
| 7–9 | 4 s → 16 s delay + CAPTCHA | Bots stall; human can solve CAPTCHA |
| 10+ | Hard lockout 15 min | Send unlock email or wait |
Return the same "Invalid credentials" error whether the email is not found OR the password is wrong. Different messages enable user enumeration — attackers discover which emails are registered.
SameSite controls whether the browser sends a cookie with cross-site requests. It is the primary modern defense against CSRF (Cross-Site Request Forgery).
| Value | Cookie sent with… | Use case |
|---|---|---|
Strict | Only same-site requests | Maximum security — cookie never sent on cross-site navigation, even links |
Lax | Same-site + top-level GET navigations | Best default — allows OAuth redirects; blocks cross-site POST |
None | All cross-site requests | Required for third-party embeds, OAuth on different domains |
res.cookie('sessionId', sid, { httpOnly: true, secure: true, // required for SameSite=None sameSite: 'lax', // 'strict' | 'lax' | 'none' maxAge: 24 * 60 * 60 * 1000, }); app.use(session({ cookie: { sameSite: 'lax', secure: process.env.NODE_ENV === 'production', httpOnly: true }, }));
<!-- Attacker's page: victim is logged into bank.com --> <form action="https://bank.com/transfer" method="POST"> <input name="amount" value="10000" /> </form> <!-- SameSite=Strict → cookie NOT sent → bank.com sees no session → BLOCKED ✓ --> <!-- SameSite=Lax → cookie NOT sent (POST) → BLOCKED ✓ --> <!-- SameSite=None → cookie IS sent → CSRF succeeds without CSRF token ✗ -->
Browsers reject SameSite=None without Secure: true. Chrome/Firefox default to Lax when not specified — but always set it explicitly.
Password reset is one of the most commonly misconfigured auth flows. A broken reset mechanism can let attackers take over any account.
const crypto = require('crypto'); // Step 1: issue token — silent if email not found async function requestReset(email) { const user = await User.findOne({ email }); if (!user) return; const rawToken = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const expiresAt = new Date(Date.now() + 3600000); // 1 hour await PasswordReset.deleteMany({ userId: user._id }); await PasswordReset.create({ userId: user._id, tokenHash, expiresAt }); await sendEmail(email, `https://app.com/reset?token=${rawToken}`); // send RAW token } // Step 2: consume token async function resetPassword(rawToken, newPassword) { const hash = crypto.createHash('sha256').update(rawToken).digest('hex'); const record = await PasswordReset.findOne({ tokenHash: hash, expiresAt: { $gt: new Date() }, }).populate('userId'); if (!record) throw new Error('Invalid or expired token'); record.userId.password = newPassword; await record.userId.save(); await PasswordReset.deleteOne({ _id: record._id }); // single-use await invalidateAllSessions(record.userId._id); }
| Mistake | Attack | Fix |
|---|---|---|
| Storing raw token in DB | DB breach → account takeover | Store SHA-256 hash; send raw in email |
| Long-lived tokens (days) | Forgotten link exploited later | 1-hour expiry max |
| Reusable tokens | Same link used multiple times | Delete after first successful use |
| Revealing email existence | "Email not found" → user enumeration | Same 200 response regardless |
| Not invalidating sessions | Attacker keeps existing session | Destroy all sessions on password change |
| Token in server access logs | Token exposed in Nginx/CDN logs | POST the token, or use a redirect |
Most JWT vulnerabilities stem from incorrect verification — trusting the token's own header for algorithm selection, using a weak secret, or skipping claim validation.
const jwt = require('jsonwebtoken'); const JWT_SECRET = process.env.JWT_SECRET; const ALLOWED_ALGORITHMS = ['HS256']; // explicit allowlist function authenticate(req, res, next) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'Missing Authorization header' }); try { const payload = jwt.verify(header.slice(7), JWT_SECRET, { algorithms: ALLOWED_ALGORITHMS, // ← CRITICAL: blocks 'none' attack issuer: 'yourdomain.com', audience: 'yourdomain.com', }); req.user = { id: payload.sub, email: payload.email, role: payload.role }; next(); } catch (err) { if (err.name === 'TokenExpiredError') return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); if (err.name === 'JsonWebTokenError') return res.status(401).json({ error: 'Invalid token' }); next(err); } }
| Vulnerability | How it works | Fix |
|---|---|---|
alg: none attack | Attacker sets {"alg":"none"} — library skips signature check | algorithms: ['HS256'] in jwt.verify() |
| RS256 → HS256 confusion | Attacker switches to HS256, signs with public key | Hardcode expected algorithm; never read from token header |
| Weak secret | Short secret brute-forced offline | crypto.randomBytes(64).toString('hex') — ≥512-bit |
| Missing iss/aud check | Token from another service accepted | Always set issuer and audience options |
| Secret in source code | Secret in git history → leaked | Load only from process.env.JWT_SECRET |
Incident response follows a strict priority: contain → assess → communicate → remediate → harden.
// 1. Rotate JWT secret — all existing tokens become invalid instantly // Deploy new JWT_SECRET env var + restart servers // 2. Invalidate all sessions in Redis await redis.flushdb(); // 3. Per-user token versioning (graceful) await User.updateMany({}, { $inc: { tokenVersion: 1 } }); // Middleware: if (payload.ver !== user.tokenVersion) → 401 // 4. Force password reset for affected accounts await User.updateMany( { _id: { $in: affectedUserIds } }, { $set: { forcePasswordReset: true } } );
const { createHash } = require('crypto'); const axios = require('axios'); async function isBreachedPassword(password) { const sha1 = createHash('sha1').update(password).digest('hex').toUpperCase(); const prefix = sha1.slice(0, 5); const { data } = await axios.get(`https://api.pwnedpasswords.com/range/${prefix}`, { headers: { 'Add-Padding': 'true' } }); return data.split('\r\n').some(line => line.startsWith(sha1.slice(5))); } if (await isBreachedPassword(newPassword)) return res.status(400).json({ error: 'Password appears in known data breaches.' });
| Post-breach hardening | Action |
|---|---|
| Upgrade password hashing | Raise bcrypt cost factor; re-hash on next login |
| Enforce MFA | Require TOTP for all accounts or high-value users |
| Anomaly detection | Flag logins from new IP/country/device for step-up auth |
| Rotate all secrets | JWT secrets, session secrets, API keys, DB passwords |
| User communication | Notify within 72 hours (GDPR) — state scope and actions required |
RBAC assigns permissions to roles, not individual users. Users are assigned one or more roles; the role carries a set of allowed actions. This simplifies management — change the role definition once, all users with that role are updated.
// Mongoose User schema const userSchema = new Schema({ email: String, password: String, role: { type: String, enum: ['user', 'moderator', 'admin'], default: 'user' }, }); // Embed role in JWT at login const token = jwt.sign( { sub: user._id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m', issuer: 'yourdomain.com' } ); // requireRole middleware — composable, variadic const requireRole = (...roles) => (req, res, next) => { if (!roles.includes(req.user?.role)) return res.status(403).json({ error: 'Insufficient permissions' }); next(); }; // Usage app.get('/api/users', authenticate, requireRole('admin'), listUsers); app.delete('/api/posts/:id', authenticate, requireRole('admin', 'moderator'), deletePost); app.get('/api/profile', authenticate, getProfile);
| Role | Typical permissions | Notes |
|---|---|---|
user | Read own data, create own content | Default for new accounts |
moderator | Edit/delete others' content | Cannot access admin panel |
admin | Full access including user management | Keep account count minimal |
Embedding role in JWT means role changes don't take effect until the token expires (up to 15 min). For immediate effect on role changes/revocations, look up the role from the database on each request (using req.user.id from the JWT) at the cost of one extra DB lookup per request.
| Aspect | RBAC | ABAC |
|---|---|---|
| Decision based on | User's role (admin, user) | Attributes of user, resource, environment |
| Policy example | "Admins can delete posts" | "Users can edit posts they own AND are published within 24h" |
| Flexibility | Low — roles are coarse-grained | High — any attribute combination |
| Implementation complexity | Low | High |
| Best for | Most SaaS apps, clear user tiers | Compliance-heavy systems, fine-grained rules |
| Node libraries | Custom middleware, casbin | casbin, oso, permit.io |
// ABAC: combine user attributes + resource attributes + context function canEditPost(user, post, context = {}) { const isOwner = post.authorId.equals(user._id); const isAdmin = user.role === 'admin'; const within24h = (Date.now() - post.createdAt) < 86_400_000; const notLocked = !post.locked; if (isAdmin) return true; if (isOwner && within24h && notLocked) return true; return false; } // In route handler const post = await Post.findById(req.params.id); if (!canEditPost(req.user, post)) return res.status(403).json({ error: 'Not authorized to edit this post' });
Most MERN apps need RBAC + ownership checks (a hybrid). Pure ABAC adds complexity; introduce it only when role-based rules become unwieldy or when compliance (HIPAA, SOC 2) demands fine-grained audit trails. casbin is the most popular Node.js library for both models.
IDOR (Insecure Direct Object Reference) is when an attacker changes an ID in a URL or body to access another user's resource. Role checks alone don't prevent it — you also need to verify the requesting user owns the specific object.
// ❌ VULNERABLE — returns any order if user is authenticated app.get('/api/orders/:id', authenticate, async (req, res) => { const order = await Order.findById(req.params.id); // no ownership check! res.json(order); }); // ✓ SAFE — scopes query to the authenticated user app.get('/api/orders/:id', authenticate, async (req, res) => { const order = await Order.findOne({ _id: req.params.id, userId: req.user.id, // ownership enforced at query level }); if (!order) return res.status(404).json({ error: 'Not found' }); // Return 404, not 403 — don't confirm the resource exists res.json(order); }); // Admin override — can see any order app.get('/api/admin/orders/:id', authenticate, requireRole('admin'), async (req, res) => { const order = await Order.findById(req.params.id); res.json(order); });
// Generic middleware factory — works for any Mongoose model const ownsResource = (Model, ownerField = 'userId') => async (req, res, next) => { const doc = await Model.findById(req.params.id).lean(); if (!doc) return res.status(404).json({ error: 'Not found' }); if (req.user.role === 'admin') return next(); // admin bypass if (!doc[ownerField].equals(req.user.id)) return res.status(404).json({ error: 'Not found' }); req.resource = doc; next(); }; app.delete('/api/posts/:id', authenticate, ownsResource(Post), deletePostHandler); app.put('/api/comments/:id', authenticate, ownsResource(Comment), updateCommentHandler);
When a user tries to access a resource they don't own, return 404 Not Found, not 403 Forbidden. A 403 confirms the resource exists — this leaks information useful for enumeration attacks.
| Factor type | Examples | Strength |
|---|---|---|
| Something you know | Password, PIN, security question | Weakest — can be guessed/phished |
| Something you have | TOTP app (Authenticator), hardware key (YubiKey), SMS OTP | Strong (TOTP), very strong (hardware key) |
| Something you are | Fingerprint, face recognition, voice | Strong but requires device support |
TOTP 2FA login flow (password + TOTP):
{ mfaRequired: true }.const speakeasy = require('speakeasy'); const QRCode = require('qrcode'); // Step 1: generate secret — store as pending until user confirms const secret = speakeasy.generateSecret({ name: `MyApp (${user.email})`, length: 20, }); await User.findByIdAndUpdate(user._id, { mfaPendingSecret: secret.base32 }); const qrDataUrl = await QRCode.toDataURL(secret.otpauth_url); res.json({ qrDataUrl }); // client renders as <img src={qrDataUrl} /> // Step 2: confirm TOTP code (verify before saving as active) const verified = speakeasy.totp.verify({ secret: user.mfaPendingSecret, encoding: 'base32', token: req.body.code, window: 1, // allow 1 interval (30s) clock drift }); if (!verified) return res.status(400).json({ error: 'Invalid code' }); await User.findByIdAndUpdate(user._id, { mfaSecret: user.mfaPendingSecret, mfaEnabled: true, mfaPendingSecret: null, backupCodes: generateBackupCodes(10), // hashed one-time codes });
Generate 10 single-use backup codes when MFA is enabled. Store their SHA-256 hashes in the DB (not plaintext). Show the raw codes once and warn the user to save them — they are the recovery path if the device is lost.
Token families group all refresh tokens issued from a single login into one family. When a refresh token is used, it is immediately replaced with a new one (rotation). If an already-used token is presented again, it means the original was stolen and used by someone else — the entire family is revoked.
// MongoDB schema const refreshTokenSchema = new Schema({ tokenHash: { type: String, required: true, unique: true }, family: { type: String, required: true }, // UUID shared by all tokens in lineage userId: { type: ObjectId, ref: 'User' }, used: { type: Boolean, default: false }, expiresAt: Date, }); // Refresh endpoint async function refreshTokens(req, res) { const rawToken = req.cookies.refreshToken; const hash = sha256(rawToken); const record = await RefreshToken.findOne({ tokenHash: hash }); if (!record) return res.status(401).json({ error: 'Invalid token' }); if (record.used) { // ⚠️ Reuse detected — token was already used → theft likely // Revoke entire family to log out attacker AND legitimate user await RefreshToken.deleteMany({ family: record.family }); return res.status(401).json({ error: 'Token reuse detected. Please log in again.' }); } if (record.expiresAt < new Date()) return res.status(401).json({ error: 'Refresh token expired' }); // Mark old token as used record.used = true; await record.save(); // Issue new token in the same family const newRawToken = crypto.randomBytes(64).toString('base64url'); await RefreshToken.create({ tokenHash: sha256(newRawToken), family: record.family, // same family userId: record.userId, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), }); const newAccessToken = issueAccessToken(record.userId); res.cookie('refreshToken', newRawToken, { httpOnly: true, secure: true, sameSite: 'strict' }); res.json({ accessToken: newAccessToken }); }
There is one false-positive scenario: if the legitimate client's response is lost in transit and they retry with the same token, the server sees a reuse and revokes the family — logging out the innocent user. Mitigate by implementing a short grace window (e.g., accept the same token for 10 seconds) or by alerting the user instead of hard-revoking.
SSO (Single Sign-On) lets users authenticate once with an Identity Provider (IdP) and access multiple Service Providers (SPs) without re-entering credentials. Common in enterprises using Okta, Azure AD, or Google Workspace.
| Protocol | Format | Best for | Node library |
|---|---|---|---|
| SAML 2.0 | XML assertions via browser POST | Legacy enterprise IdPs (Okta, ADFS, Ping) | passport-saml |
| OIDC | JSON/JWT via OAuth 2.0 flows | Modern cloud IdPs (Google, Auth0, Okta OIDC) | openid-client, passport-openidconnect |
const { Strategy: SamlStrategy } = require('passport-saml'); passport.use(new SamlStrategy({ entryPoint: process.env.SAML_ENTRY_POINT, // IdP SSO URL issuer: 'https://yourdomain.com', // SP entity ID callbackUrl: 'https://yourdomain.com/auth/saml/callback', cert: process.env.SAML_CERT, // IdP public cert for signature verification wantAssertionsSigned: true, }, async (profile, done) => { // profile.nameID = user's email / unique ID from IdP let user = await User.findOne({ samlId: profile.nameID }); if (!user) { user = await User.create({ samlId: profile.nameID, email: profile.email || profile.nameID, role: 'user', }); } return done(null, user); })); app.get('/auth/saml', passport.authenticate('saml')); app.post('/auth/saml/callback', passport.authenticate('saml'), (req, res) => { const token = issueJWT(req.user); res.redirect(`${process.env.CLIENT_URL}/auth/callback?token=${token}`); });
If the enterprise IdP supports both, prefer OIDC — it is simpler, uses JSON/JWT instead of XML, and works seamlessly with existing OAuth flows. Use SAML only when the IdP mandates it or when you need to support legacy ADFS environments.
Zero Trust replaces the old "trusted internal network" model with "never trust, always verify". Every request — even from within the VPC — must be authenticated and authorized. Trust is never implicit based on network location.
| ZT Principle | What it means in practice |
|---|---|
| Verify explicitly | Authenticate every request — JWTs, mTLS between services, no session persistence |
| Least privilege | RBAC + scoped tokens — access tokens grant only what is needed for one operation |
| Assume breach | Log everything, monitor anomalies, segment access even for admins |
| Continuous validation | Short-lived access tokens (5–15 min), re-verify on sensitive operations |
| Device trust | Consider device posture (managed vs unmanaged) in authorization decisions |
// 1. Short-lived access tokens (15 min max) const accessToken = jwt.sign({ sub: user._id, role: user.role }, JWT_SECRET, { expiresIn: '15m' }); // 2. Scoped tokens — token can only call specific endpoints const exportToken = jwt.sign({ sub: user._id, scope: 'export:csv', // single-purpose token }, JWT_SECRET, { expiresIn: '5m' }); // Middleware: if (!payload.scope?.includes('export:csv')) → 403 // 3. Step-up auth — re-verify for high-value actions const requireFreshAuth = (maxAgeSeconds = 300) => (req, res, next) => { const issuedAt = req.user.iat * 1000; if (Date.now() - issuedAt > maxAgeSeconds * 1000) return res.status(401).json({ error: 'Re-authentication required', code: 'STALE_TOKEN' }); next(); }; app.delete('/api/account', authenticate, requireFreshAuth(60), deleteAccount); // 4. Service-to-service mTLS (handled at Nginx/Istio layer) // Express sees req.client.authorized === true when client cert is valid app.use((req, res, next) => { if (!req.client.authorized) return res.status(401).end(); next(); });
Permission strings (e.g., posts:delete:own) are more granular than roles and make authorization logic explicit and auditable. Each role maps to a set of permissions; middleware checks permissions, not roles directly.
// Permission registry — define all possible permissions const PERMISSIONS = { 'posts:read': true, 'posts:create': true, 'posts:delete:own': true, // user can delete only their own 'posts:delete:any': true, // admin/moderator can delete anyone's 'users:read': true, 'users:ban': true, }; // Role → permission mapping const ROLE_PERMISSIONS = { user: ['posts:read', 'posts:create', 'posts:delete:own'], moderator: ['posts:read', 'posts:create', 'posts:delete:any', 'users:read'], admin: Object.keys(PERMISSIONS), // all permissions }; function hasPermission(role, permission) { return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; } // Middleware factory const requirePermission = (permission) => (req, res, next) => { if (!hasPermission(req.user?.role, permission)) return res.status(403).json({ error: `Requires permission: ${permission}` }); next(); }; // Usage app.post('/api/posts', authenticate, requirePermission('posts:create'), createPost); app.delete('/api/posts/:id', authenticate, requirePermission('posts:delete:any'), deletePost); app.post('/api/users/:id/ban',authenticate, requirePermission('users:ban'), banUser);
For APIs that need to run without a DB lookup on every request, embed a compact permissions array in the JWT at login: { sub, role, perms: ['posts:read', 'posts:create'] }. Trade-off: token size grows, and permission changes don't take effect until the token is refreshed.
| Aspect | Local JWT Verification | Token Introspection (RFC 7662) |
|---|---|---|
| Mechanism | Verify signature + claims locally | Call auth server: POST /oauth/introspect |
| Latency | ~0 ms (no network) | ~10–50 ms (network round-trip) |
| Revocation aware | No — can't check if token was revoked | Yes — auth server checks revocation list |
| Best for | Stateless APIs, high-throughput endpoints | High-security ops, post-breach, financial transactions |
// Auth server — introspection endpoint (RFC 7662) app.post('/oauth/introspect', async (req, res) => { const { token } = req.body; try { const payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); const revoked = await RevokedToken.exists({ jti: payload.jti }); if (revoked) return res.json({ active: false }); res.json({ active: true, sub: payload.sub, exp: payload.exp, scope: payload.scope, }); } catch { res.json({ active: false }); } }); // Resource server — call introspect for high-value endpoints async function introspectMiddleware(req, res, next) { const token = req.headers.authorization?.slice(7); const { data } = await axios.post('https://auth.yourdomain.com/oauth/introspect', { token }, { auth: { username: CLIENT_ID, password: CLIENT_SECRET } }); if (!data.active) return res.status(401).json({ error: 'Token is inactive or revoked' }); req.user = { id: data.sub }; next(); } // Use introspect only where revocation matters app.post('/api/payments', introspectMiddleware, processPayment); app.get('/api/feed', authenticate, getFeed); // local verify is fine
A production auth middleware stack composes multiple concerns in a deliberate order. Each layer short-circuits on failure — later layers never run if earlier ones reject the request.
// middleware/auth.js — single import surface module.exports = { authenticate, // verify JWT, populate req.user requireRole, // role check requirePermission, // permission string check ownsResource, // object-level ownership requireFreshAuth, // ZT step-up (token age check) loginLimiter, // rate limiter for login auditLog, // write to audit collection }; // Usage — layers run left to right, short-circuit on error app.post('/api/auth/login', loginLimiter, // 1. Rate limit BEFORE authentication loginController ); app.get('/api/posts', authenticate, // 2. Verify JWT, set req.user requirePermission('posts:read'), // 3. Check permission getPosts ); app.delete('/api/posts/:id', authenticate, requirePermission('posts:delete:own'), ownsResource(Post), // 4. Verify ownership (IDOR check) auditLog, // 5. Write audit trail deletePost ); app.delete('/api/account', authenticate, requireFreshAuth(60), // ZT: token must be < 60s old auditLog, deleteAccount ); // Centralized error handler — catches 401/403 from all middleware app.use((err, req, res, next) => { if (err.status) return res.status(err.status).json({ error: err.message }); console.error(err); res.status(500).json({ error: 'Internal server error' }); });
| Layer | Position | Why |
|---|---|---|
| Rate limiter | First | Block bots before any crypto/DB work |
| authenticate | Second | Establishes identity; everything after depends on it |
| requireRole / requirePermission | Third | Coarse filter before expensive ownership checks |
| ownsResource | Fourth | DB query — run only when role check passes |
| requireFreshAuth | Before sensitive ops | ZT re-verification; only needed on destructive actions |
| auditLog | Just before handler | Logs only requests that passed all auth checks |
The complete flow spans registration → login → API access → silent refresh → logout. Each step has specific security considerations.
{ email, password }. Express hashes password with bcrypt (cost 12), stores user. Returns 201 — no tokens yet.bcrypt.compare(). Issues a short-lived access token (15 min, JWT) and a long-lived refresh token (30 days, random bytes stored as SHA-256 hash in DB). Access token returned in JSON body; refresh token set as HttpOnly; Secure; SameSite=Strict cookie.localStorage). Axios interceptor attaches it as Authorization: Bearer <token> on every request.401 { code: 'TOKEN_EXPIRED' }. Axios response interceptor catches it, calls POST /auth/refresh (cookie sent automatically), receives new access token, retries the original request.res.clearCookie().async function login(req, res) { const user = await User.findOne({ email: req.body.email }); if (!user || !await user.comparePassword(req.body.password)) return res.status(401).json({ error: 'Invalid credentials' }); // Access token — short-lived, returned in body const accessToken = jwt.sign( { sub: user._id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m', issuer: 'yourdomain.com' } ); // Refresh token — long-lived, HttpOnly cookie const rawRefresh = crypto.randomBytes(64).toString('base64url'); await RefreshToken.create({ tokenHash: sha256(rawRefresh), userId: user._id, family: crypto.randomUUID(), expiresAt: new Date(Date.now() + 30 * 86_400_000), }); res.cookie('refreshToken', rawRefresh, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 30 * 86_400_000, }); res.json({ accessToken, user: { id: user._id, email: user.email, role: user.role } }); }
import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAuth } from './AuthContext'; // Base auth guard — redirects to /login if not authenticated export function RequireAuth() { const { user, loading } = useAuth(); const location = useLocation(); if (loading) return <div>Loading…</div>; // wait for initial auth check if (!user) return <Navigate to="/login" state={{ from: location }} replace />; return <Outlet />; } // Role guard — renders 403 if user lacks required role export function RequireRole({ roles }) { const { user } = useAuth(); if (!roles.includes(user?.role)) return <Navigate to="/403" replace />; return <Outlet />; } // Router setup <Routes> <Route path="/login" element={<LoginPage />} /> <Route path="/register" element={<RegisterPage />} /> <Route element={<RequireAuth />}> {/* all below require login */} <Route path="/" element={<Dashboard />} /> <Route path="/profile" element={<Profile />} /> <Route element={<RequireRole roles={['admin']} />}> {/* admin only */} <Route path="/admin" element={<AdminPanel />} /> </Route> </Route> </Routes>
Pass state={{ from: location }} when redirecting to login. After successful login, redirect to location.state?.from?.pathname ?? '/' so the user lands on the page they originally tried to access — a much better UX than always landing on the dashboard.
React route guards only control what is rendered. They are trivially bypassed in DevTools. The real access control is on the server — every protected API endpoint must independently verify the JWT and check roles. Never rely on React guards for security.
The Axios interceptor catches every 401 TOKEN_EXPIRED response, transparently refreshes the access token, and retries the original request — all invisible to the calling code. The key challenge is deduplication: if 5 concurrent requests all expire at the same time, you must issue only one refresh call and queue the others.
import axios from 'axios'; const api = axios.create({ baseURL: '/api', withCredentials: true }); let accessToken = null; // stored in module scope (memory) let refreshPromise = null; // deduplication lock export const setAccessToken = (t) => { accessToken = t; }; // Request interceptor — attach token api.interceptors.request.use((config) => { if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; return config; }); // Response interceptor — handle 401 api.interceptors.response.use( (res) => res, async (error) => { const original = error.config; const is401 = error.response?.status === 401; const isExpiry = error.response?.data?.code === 'TOKEN_EXPIRED'; if (!is401 || !isExpiry || original._retry) return Promise.reject(error); original._retry = true; // prevent infinite retry loop try { // Deduplication: reuse an in-flight refresh for concurrent expired requests if (!refreshPromise) { refreshPromise = axios .post('/api/auth/refresh', {}, { withCredentials: true }) .then(({ data }) => { setAccessToken(data.accessToken); return data.accessToken; }) .finally(() => { refreshPromise = null; }); } const newToken = await refreshPromise; original.headers.Authorization = `Bearer ${newToken}`; return api(original); // retry original request with new token } catch { setAccessToken(null); window.location.replace('/login'); // refresh failed → force logout return Promise.reject(error); } } );
In-memory access tokens are lost on page reload. On app mount, call POST /auth/refresh immediately to silently restore the access token using the persisted HttpOnly cookie. Show a loading spinner until this resolves — never render protected routes until auth state is established.
| Storage | Readable by JS | Persists page reload | XSS risk | CSRF risk | Verdict |
|---|---|---|---|---|---|
localStorage | Yes | Yes | High — any script reads it | No | ❌ Avoid for auth tokens |
sessionStorage | Yes | No (lost on tab close) | High | No | ❌ Still XSS-vulnerable |
| Memory (module var) | Yes | No (lost on reload) | Low — not in DOM | No | ✓ Access token only |
| HttpOnly cookie | No | Yes | None — JS can't read it | Low (SameSite=Strict) | ✓ Refresh token |
Recommended pattern for MERN SPAs:
// Server: login response res.cookie('refreshToken', rawRefresh, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 30 * 86_400_000, }); res.json({ accessToken }); // short-lived JWT in response body // Client: store access token in module scope (not localStorage) let _accessToken = null; export const setToken = (t) => { _accessToken = t; }; export const getToken = () => _accessToken; // On page reload — silently restore from cookie async function restoreSession() { try { const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true }); setToken(data.accessToken); return data.user; } catch { return null; // no valid cookie → user not logged in } }
Any XSS vulnerability — including one in a third-party script — can read localStorage and exfiltrate your token silently. An HttpOnly cookie is invisible to JavaScript entirely; even a fully compromised page cannot read it.
import { createContext, useContext, useReducer, useEffect } from 'react'; import { setToken, restoreSession } from './api'; const AuthContext = createContext(null); const authReducer = (state, action) => { switch (action.type) { case 'INIT': return { ...state, user: action.user, loading: false }; case 'LOGIN': return { ...state, user: action.user }; case 'LOGOUT': return { user: null, loading: false }; default: return state; } }; export function AuthProvider({ children }) { const [state, dispatch] = useReducer(authReducer, { user: null, loading: true }); useEffect(() => { // Silently restore session on every page load restoreSession().then((user) => dispatch({ type: 'INIT', user })); }, []); const login = (user, token) => { setToken(token); dispatch({ type: 'LOGIN', user }); }; const logout = async () => { await api.post('/auth/logout'); // clears server-side cookie setToken(null); dispatch({ type: 'LOGOUT' }); }; return ( <AuthContext.Provider value={{ ...state, login, logout }}> {state.loading ? <SplashScreen /> : children} </AuthContext.Provider> ); } export const useAuth = () => useContext(AuthContext);
Render a <SplashScreen /> (or skeleton) while loading: true. If you render protected routes before the silent restore resolves, the RequireAuth guard sees user: null and immediately redirects to login — even for authenticated users. The loading gate prevents this flash.
| Aspect | React + Express (SPA) | Next.js (App Router) |
|---|---|---|
| Auth layer | Express middleware | Route handlers + middleware.ts |
| Token access | Client reads from memory, sends in header | Server components read session from cookie directly |
| Route protection | React RequireAuth component | middleware.ts runs on edge before page loads |
| Auth library | Custom or Passport.js | Auth.js (NextAuth v5) recommended |
| OAuth | Passport strategy, manual callback | Auth.js providers: GoogleProvider(), one config |
// auth.ts import NextAuth from 'next-auth'; import Google from 'next-auth/providers/google'; import Credentials from 'next-auth/providers/credentials'; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ Google({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET }), Credentials({ async authorize({ email, password }) { const user = await verifyCredentials(email, password); return user ?? null; }, }), ], callbacks: { async session({ session, token }) { session.user.id = token.sub; session.user.role = token.role; return session; }, async jwt({ token, user }) { if (user) token.role = user.role; return token; }, }, }); // middleware.ts — protect routes at the edge (runs before page renders) import { auth } from './auth'; export default auth; export const config = { matcher: ['/dashboard/:path*', '/admin/:path*'] }; // Server component — read session without useEffect or loading state export default async function DashboardPage() { const session = await auth(); // direct session access, no API call if (!session) redirect('/login'); return <Dashboard user={session.user} />; }
| Bug | Symptom | Fix |
|---|---|---|
| Auth only checked on login | Manually navigating to /dashboard works without token | Verify JWT on every protected API request, not just at login |
Token stored in localStorage | Any XSS steals session | Memory for access token, HttpOnly cookie for refresh token |
| No loading gate in React | Flash of unauthenticated UI, redirect loop on reload | Render splash until silent restore resolves |
Missing withCredentials: true | Cookies not sent on cross-origin requests | Set in Axios defaults and CORS credentials: true |
| CORS not configured for cookies | Refresh token cookie blocked by browser | cors({ origin: CLIENT_URL, credentials: true }) — cannot use origin: '*' with credentials |
| No concurrent refresh deduplication | 5 parallel requests → 5 refresh calls → race condition | Single refreshPromise shared across interceptor calls |
| Role stored only in frontend | User edits JS to grant themselves admin | Always re-read role from JWT/DB on server; never trust client claims |
Missing algorithms in jwt.verify | alg:none attack bypasses signature check | jwt.verify(token, secret, { algorithms: ['HS256'] }) |
const cors = require('cors'); app.use(cors({ origin: process.env.CLIENT_URL, // exact origin — NOT '*' with credentials credentials: true, // allows cookies to be sent cross-origin methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], }));
Auth testing spans three levels: unit tests for middleware logic, integration tests for full HTTP flows, and E2E tests for the browser-side login journey.
const jwt = require('jsonwebtoken'); const { authenticate } = require('../middleware/auth'); const mockRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); test('rejects missing header', () => { const req = { headers: {} }, res = mockRes(), next = jest.fn(); authenticate(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); test('accepts valid JWT and populates req.user', () => { const token = jwt.sign({ sub: 'u1', role: 'user' }, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'yourdomain.com', audience: 'yourdomain.com' }); const req = { headers: { authorization: `Bearer ${token}` } }; const next = jest.fn(); authenticate(req, mockRes(), next); expect(next).toHaveBeenCalled(); expect(req.user.id).toBe('u1'); });
const request = require('supertest'); const app = require('../app'); test('full login → protected route flow', async () => { // 1. Login const loginRes = await request(app) .post('/api/auth/login') .send({ email: 'test@test.com', password: 'correct-password' }); expect(loginRes.status).toBe(200); const { accessToken } = loginRes.body; // 2. Access protected route const profileRes = await request(app) .get('/api/profile') .set('Authorization', `Bearer ${accessToken}`); expect(profileRes.status).toBe(200); // 3. Expired token → 401 const expired = jwt.sign({ sub: 'u1' }, process.env.JWT_SECRET, { expiresIn: '-1s' }); const expiredRes = await request(app) .get('/api/profile') .set('Authorization', `Bearer ${expired}`); expect(expiredRes.status).toBe(401); expect(expiredRes.body.code).toBe('TOKEN_EXPIRED'); });
- Missing Authorization header → 401
- Malformed JWT (truncated) → 401
- Expired token → 401 with
TOKEN_EXPIREDcode - Valid token, wrong role → 403
- Valid token, wrong resource owner → 404
- Refresh with used token → 401 (reuse detection)
Auth bugs always live at one of five layers. Work from the bottom up — don't debug React state until you've confirmed the API works in isolation.
Authorization header present? Is the cookie being sent? Is the CORS preflight passing? Is the request reaching the server (check server logs)?exp in the future? Does the algorithm match what the server expects?console.log(req.headers, req.user) at the start of the auth middleware. Is req.user populated? Is the JWT_SECRET the same on the signing and verifying side?req.user.role and the required role. Check if the permission string matches exactly (case-sensitive).loading blocking the render?// Temporary debug middleware — add before authenticate app.use((req, res, next) => { console.log(`[AUTH DEBUG] ${req.method} ${req.path}`); console.log('Authorization:', req.headers.authorization?.slice(0, 30) + '...'); console.log('Cookies:', req.cookies); console.log('Origin:', req.headers.origin); next(); });
The right auth strategy depends on your app type, scale, and security requirements. This framework covers the most common scenarios.
| Scenario | Recommended Strategy | Why |
|---|---|---|
| Standard SPA with own backend | JWT (access) + HttpOnly refresh token | Stateless, scalable, works across multiple API servers |
| SSR / Next.js app | Auth.js (NextAuth) with database session | Server components read session directly; no client token juggling |
| Multi-tenant B2B SaaS | OIDC/SAML SSO + RBAC | Enterprises require IdP federation; role must map to tenant context |
| Mobile app (React Native) | JWT stored in SecureStore + refresh rotation | No HttpOnly cookies in mobile; use OS secure storage |
| Microservices / internal APIs | mTLS + short-lived service tokens | Zero Trust; no human user — machines authenticate each other |
| Public-facing API (developer API) | API keys (hashed, scoped, rotatable) | Simple, stateless, easy to revoke per-key |
| Startup / MVP | Managed auth (Clerk, Auth0, Supabase) | Ship faster; security handled by specialists; switch later if needed |
| High-security (banking, health) | Sessions + MFA + step-up auth | Stateful = instant revocation; MFA blocks credential attacks |
If auth is not your core business, seriously consider Clerk, Auth0, or Supabase Auth. They handle MFA, device tracking, breach detection, compliance (SOC 2, GDPR), and social OAuth out of the box. The cost in dollars is almost always less than the engineering hours to build and maintain a secure custom solution.
Regardless of which strategy you pick: verify on the server, every time, for every request. The auth strategy determines how you issue and transmit credentials — but server-side verification is non-negotiable in all cases.