// JWT structure: header.payload.signature
// eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.signature
const jwt = require('jsonwebtoken');
// Create token
const token = jwt.sign({ userId: 1 }, 'secret', { expiresIn: '1h' });
// Verify token
const decoded = jwt.verify(token, 'secret');
console.log(decoded.userId); // 1
Why it matters: JWT is the most common stateless authentication mechanism in modern web APIs. Interviewers test this to assess whether you understand the token structure, the difference between signing algorithms, and the security implications of storing user data in a client-held token.
Real applications: Single-page applications use JWTs to authenticate API requests after login without maintaining server-side sessions. Microservice architectures pass JWTs between services to carry user identity and authorization claims without querying a shared session store.
Common mistakes: Storing sensitive data like passwords or PII in the JWT payload — it is base64-encoded, not encrypted, so anyone can decode it. Developers also use weak secrets or forget to validate the exp claim, allowing expired tokens to remain valid indefinitely.
const bcrypt = require('bcrypt');
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// Hash password with salt rounds
const hashedPassword = await bcrypt.hash(password, 12);
const user = await User.create({
email,
password: hashedPassword
});
res.status(201).json({ id: user.id, email: user.email });
});
Why it matters: Password security is a fundamental backend concern, and interviewers test whether you know to never store plaintext passwords. They also assess whether you understand the difference between hashing algorithms (bcrypt vs SHA256) and why bcrypt's intentional slowness is actually a security feature.
Real applications: Every user-facing application with authentication — social platforms, SaaS tools, e-commerce sites — uses bcrypt or similar adaptive hashing functions like Argon2 when persisting user credentials to the database.
Common mistakes: Using fast hashing algorithms like SHA-256 or MD5 for passwords — these are designed for speed which makes them trivially brute-forceable. Developers also forget to validate password strength server-side, allowing weak passwords that pass through and get hashed just as easily as strong ones.
bcrypt.compare(), and issue a JWT on successful authentication. Return the same generic error message for wrong email or password to prevent user enumeration attacks that could reveal which accounts exist. Always store the JWT secret in environment variables and include only essential, non-sensitive data in the token payload.
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token, user: { id: user.id, email: user.email } });
});
Why it matters: Login is the most security-critical endpoint in any application. Interviewers test this to check whether you know about timing-safe comparison, user enumeration prevention, token expiration best practices, and keeping sensitive data out of JWT payloads.
Real applications: Every web application login flow uses this pattern — comparing the bcrypt hash and issuing a short-lived JWT. Mobile apps and SPAs store the JWT in memory or secure storage and send it with every API request in the Authorization header.
Common mistakes: Using string equality instead of bcrypt.compare() for password checking is both insecure and incorrect. Returning different error messages for wrong email vs wrong password also enables attackers to enumerate valid email addresses in your system.
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
app.get('/profile', authenticate, (req, res) => {
res.json({ userId: req.user.userId });
});
Why it matters: Authentication middleware is the gatekeeper for all protected API endpoints. Interviewers test it to verify you understand centralized security logic, Bearer token format, and proper error handling that does not leak information about why a token failed verification.
Real applications: REST APIs protecting user-specific resources like profile data, orders, and settings all funnel requests through authentication middleware before reaching business logic. The middleware pattern keeps security concerns separated from route handler logic throughout the application.
Common mistakes: Adding authentication logic directly in individual route handlers instead of using reusable middleware leads to inconsistent protection. Developers also forget to handle the case where the Authorization header is missing entirely, causing undefined errors when trying to split the token string.
authorize middleware uses a closure to accept a list of allowed roles, returning 403 Forbidden if the user's role is not in the allowed list. This pattern keeps authorization logic reusable and separate from route business logic.
const authorize = (...roles) => {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
// Usage — chain authenticate then authorize
app.delete('/users/:id',
authenticate,
authorize('admin'),
deleteUser
);
app.get('/dashboard',
authenticate,
authorize('admin', 'manager'),
getDashboard
);
Why it matters: Virtually every real application has multiple user types with different access levels. Interviewers test RBAC to verify you understand the difference between authentication (who are you?) and authorization (what are you allowed to do?), and how to implement both at the middleware level.
Real applications: SaaS platforms implement admin dashboards that only admin role users can access, while content management systems restrict publishing rights to editor and admin roles. The pattern chains cleanly with authenticate middleware on any Express route.
Common mistakes: Performing authorization checks inside route handlers instead of reusable middleware leads to duplicated logic and security gaps where a developer forgets to add the check. Encoding roles in the JWT but not validating them on every request is also a common oversight that defeats the purpose of RBAC.
app.post('/login', async (req, res) => {
// After verifying credentials...
const accessToken = jwt.sign({ userId }, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, refreshSecret, { expiresIn: '7d' });
await saveRefreshToken(userId, refreshToken); // Store in DB
res.json({ accessToken, refreshToken });
});
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
const decoded = jwt.verify(refreshToken, refreshSecret);
const stored = await findRefreshToken(decoded.userId, refreshToken);
if (!stored) return res.status(401).json({ error: 'Invalid refresh token' });
const newAccessToken = jwt.sign({ userId: decoded.userId }, secret, { expiresIn: '15m' });
res.json({ accessToken: newAccessToken });
});
Why it matters: Short-lived access tokens alone require frequent re-login which damages UX. Interviewers test the refresh token pattern to assess whether you understand the security/UX tradeoff and know how to implement token rotation and revocation correctly.
Real applications: Mobile banks and fintech apps use 15-minute access tokens with 30-day refresh tokens — the short access token window limits blast radius if stolen, while the refresh token provides seamless re-authentication in the background.
Common mistakes: Storing refresh tokens in localStorage exposes them to XSS attacks — they should be stored in httpOnly cookies. Developers also forget to implement token rotation (issuing a new refresh token on each use and invalidating the old one), leaving stolen refresh tokens permanently valid.
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
let user = await User.findOne({ googleId: profile.id });
if (!user) user = await User.create({ googleId: profile.id, name: profile.displayName });
done(null, user);
}
));
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback', passport.authenticate('google'), (req, res) => {
const token = generateJWT(req.user);
res.redirect(`/dashboard?token=${token}`);
});
Why it matters: OAuth social login is expected in most modern applications. Interviewers test Passport.js knowledge to verify you understand the OAuth 2.0 flow, how to handle provider callbacks, and how to map external OAuth profiles to internal user accounts securely.
Real applications: SaaS platforms offer "Sign in with Google" or "Sign in with GitHub" to reduce registration friction. Dev tools like CI/CD platforms authenticate exclusively via GitHub OAuth to match their developer persona without requiring a separate password.
Common mistakes: Storing OAuth client secrets in source code or committing them to repositories is a critical security failure. Developers also forget to handle the case where a user signs in with a different provider (Google vs GitHub) using the same email, resulting in duplicate accounts in the database.
// Generate a strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(64).toString('hex');
// Store in environment variables
// .env file (never commit this)
JWT_SECRET=your_generated_secret_here
JWT_REFRESH_SECRET=another_strong_secret
// Access in code
const token = jwt.sign(payload, process.env.JWT_SECRET);
Why it matters: Poor secret management is one of the most common and catastrophic security mistakes. Interviewers test this to confirm you understand secrets hygiene — not hardcoding keys, rotating them periodically, and using environment-specific secrets for development vs production.
Real applications: Enterprise applications store JWT secrets in AWS Secrets Manager with automatic rotation policies. CI/CD pipelines inject secrets as environment variables at deployment time, keeping them out of the codebase and build artefacts entirely.
Common mistakes: Committing .env files or hardcoding secrets to version control exposes them permanently — even if removed later, they remain in git history. Developers also reuse the same secret across all environments, making it impossible to rotate just the compromised production secret without affecting all others.
// Token blacklist using Redis
const redis = require('redis');
const client = redis.createClient();
app.post('/logout', authenticate, async (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
// Blacklist token until it expires
await client.setEx(`blacklist:${token}`, ttl, 'true');
res.json({ message: 'Logged out' });
});
// Check blacklist in auth middleware
const isBlacklisted = await client.get(`blacklist:${token}`);
if (isBlacklisted) return res.status(401).json({ error: 'Token revoked' });
Why it matters: JWT logout reveals a fundamental tradeoff in stateless authentication. Interviewers test this to assess whether you understand the stateless JWT limitation and the common mitigation strategies for scenarios requiring immediate token revocation, such as account compromise.
Real applications: Banking and fintech applications use Redis blacklisting to immediately invalidate tokens when users report account compromise or change their password. Apps with a "sign out all devices" feature store a per-user token version in the database, rejecting any token issued before the current version.
Common mistakes: Assuming logout simply means deleting the token from the client — the token remains valid on the server until expiry. Developers also blacklist using a local in-memory Set instead of Redis, breaking logout when running multiple server instances since the blacklist is not shared.
const token = jwt.sign(payload, secret, {
expiresIn: '15m',
issuer: 'myapp.com',
audience: 'myapp-client'
});
Why it matters: Security best practices questions test whether you have a holistic understanding of JWT security beyond just generating and verifying tokens. Interviewers check whether you know about claim validation, algorithm choices, transport security, and payload minimization as layered defenses.
Real applications: Financial APIs set iss, aud, and exp claims to prevent tokens intended for one service from being accepted by another. API gateways validate these claims before routing requests to microservices, providing a central security enforcement point.
Common mistakes: Accepting JWTs with the alg: none header allows attackers to forge tokens without a signature. Developers also omit iss and aud validation, enabling token replay attacks where a valid token from one application is used to authenticate against a different service.
speakeasy and otplib handle TOTP generation and verification in Node.js.
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Generate secret during MFA setup
app.post('/mfa/setup', authenticate, async (req, res) => {
const secret = speakeasy.generateSecret({
name: 'MyApp (' + req.user.email + ')'
});
// Store secret in user record
await User.updateOne(
{ _id: req.user.id },
{ mfaSecret: secret.base32, mfaEnabled: false }
);
// Generate QR code for authenticator app
const qrUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ qrCode: qrUrl, secret: secret.base32 });
});
// Verify MFA code during login
app.post('/mfa/verify', async (req, res) => {
const { token, tempToken } = req.body;
const user = jwt.verify(tempToken, process.env.JWT_SECRET);
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token,
window: 1 // Allow 1 step tolerance
});
if (!verified) return res.status(401).json({ error: 'Invalid code' });
const accessToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
res.json({ accessToken });
});
Why it matters: MFA is a key security requirement in enterprise and compliance-sensitive applications. Interviewers test this to gauge whether you can build authentication flows beyond simple username/password, and whether you understand the TOTP algorithm and its time-window tolerance implementation.
Real applications: Financial platforms, GitHub, AWS, and enterprise SaaS applications enforce MFA for admin accounts. Healthcare and banking apps mandate MFA for all users to comply with regulations like HIPAA and PSD2, making this a practical requirement rather than an optional feature.
Common mistakes: Storing the TOTP secret in plaintext in the database — it should be encrypted at rest since it permanently grants access to generate valid codes. Developers also forget to provide backup codes during MFA setup, leaving users permanently locked out if they lose their authenticator device.
x-api-key header or as a query parameter. Each key should be hashed before storage using SHA-256 (not bcrypt — speed is acceptable here since pre-image resistance is the goal) to prevent exposure if the database is breached.
const crypto = require('crypto');
// Generate API key
function generateApiKey() {
return crypto.randomBytes(32).toString('hex');
}
// Hash for storage (never store plaintext)
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
// API key middleware
const apiKeyAuth = async (req, res, next) => {
const key = req.headers['x-api-key'];
if (!key) return res.status(401).json({ error: 'API key required' });
const hashedKey = hashApiKey(key);
const apiKey = await ApiKey.findOne({ keyHash: hashedKey, active: true });
if (!apiKey) return res.status(401).json({ error: 'Invalid API key' });
req.apiClient = apiKey.clientName;
next();
};
app.get('/api/data', apiKeyAuth, (req, res) => {
res.json({ client: req.apiClient, data: [] });
});
Why it matters: API keys are the standard authentication mechanism for developer-facing APIs, webhooks, and third-party integrations. Interviewers test this to verify you understand how to securely generate, store, and validate API keys without exposing them in logs or breach scenarios.
Real applications: Stripe, Twilio, and Sendgrid all authenticate via API keys in request headers. Internal microservices use API keys to authenticate service-to-service calls without the complexity of JWT token rotation while still maintaining auditability per client.
Common mistakes: Storing API keys in plaintext means a database breach exposes all keys permanently. Developers also log request headers without redacting the x-api-key header, inadvertently exposing keys in log aggregation systems accessible by the broader team.
csrf-csrf) is a modern CSRF protection approach that works well with SPAs and cookie-based auth.
const { doubleCsrf } = require('csrf-csrf');
const { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__csrf',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production'
}
});
// Apply CSRF protection
app.use(doubleCsrfProtection);
// Provide token to client
app.get('/csrf-token', (req, res) => {
res.json({ token: generateToken(req, res) });
});
// Protected route — token validated automatically
app.post('/transfer', (req, res) => {
// CSRF token already validated by middleware
processTransfer(req.body);
});
Why it matters: CSRF remains a relevant attack vector for cookie-based authentication systems. Interviewers test this to determine whether you understand when CSRF protection is necessary (cookie auth) versus unnecessary (JWT in Authorization header), and how to implement it correctly.
Real applications: Traditional server-rendered web apps and APIs using session cookies require CSRF protection on all state-changing endpoints. Banking and e-commerce applications use strict CSRF tokens with SameSite: Strict cookies to prevent unauthorized fund transfers and order placement.
Common mistakes: Not implementing CSRF protection on cookie-authenticated APIs assuming only browsers are clients — automated CSRF attacks work via server-side HTTP requests too. Developers using JWT in the Authorization header incorrectly add CSRF protection, adding unnecessary complexity since JWTs stored in memory are not subject to CSRF.
express-session middleware handles session creation, storage, and cookie management, but requires an external session store (Redis, MongoDB) in any multi-instance deployment.
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const redisClient = redis.createClient();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'strict'
}
}));
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body);
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
app.post('/logout', (req, res) => {
req.session.destroy(() => res.json({ message: 'Logged out' }));
});
Why it matters: Understanding stateful vs stateless auth trade-offs is critical for choosing the right strategy — sessions are simpler for monoliths, while JWTs suit distributed or mobile-first apps.
Real applications: E-commerce sites, banking portals, and classic CMS-backed platforms (WordPress-style) still rely heavily on session-based auth with Redis session stores.
Common mistakes: Using the default in-memory MemoryStore in production causes memory leaks; always swap it for connect-redis or connect-mongo and set httpOnly, secure, and sameSite cookie flags.
const fs = require('fs');
const jwt = require('jsonwebtoken');
// Symmetric (HS256) — same secret for sign and verify
const hsToken = jwt.sign({ userId: 1 }, 'shared-secret', { algorithm: 'HS256' });
jwt.verify(hsToken, 'shared-secret');
// Asymmetric (RS256) — private key to sign, public key to verify
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
const rsToken = jwt.sign({ userId: 1 }, privateKey, { algorithm: 'RS256' });
const decoded = jwt.verify(rsToken, publicKey);
// Generate RSA keys:
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
Why it matters: Choosing the right signing algorithm directly affects token security and key management complexity, especially in multi-service architectures.
Real applications: API gateways, OAuth 2.0 authorization servers, and microservice platforms like AWS API Gateway and Auth0 use RS256 so services never need access to the signing private key.
Common mistakes: Using HS256 across multiple microservices means every service holds the signing secret — if any service is compromised, an attacker can forge tokens; use RS256 instead.