Security
Defense in depth: the breach lives in the seam
The login flow is textbook. Passwords are Argon2id-hashed, the session is a short-lived JWT, TLS is enforced with HSTS, the API key sits in a vault. Then a user changes the id in GET /accounts/{id}/statements and reads someone else’s bank statements. No control was broken. Authentication verified who the caller was — flawlessly — and then the statements endpoint simply never asked whether this caller may read this record. Every layer passed its own test. The breach lived in the gap between them.
One request, six layers
Trace a single sensitive action — a logged-in user transfers money — and you pass through every control the security track has covered. Each is a distinct layer with its own job, and a senior reasons about them as a stack, not a checklist:
- Transport — TLS plus HSTS so the request can’t be read or downgraded to plaintext on the wire.
- Authentication — prove who is calling. A short-lived access token (minutes, not days), passwords stored as Argon2id or bcrypt hashes, never the password itself.
- Authorization — decide whether this caller may do this to this object. Deny-by-default, least privilege. This is OWASP’s #1 risk for a reason.
- Input handling — treat every field as hostile: validate, parameterize queries, encode output.
- Secrets — keys and DB credentials live in a vault or env, never in the repo, never in the JWT.
- Supply chain — the dependencies executing all of the above are themselves vetted and pinned.
The thesis of the whole track lands here: defense in depth assumes every control eventually fails. You stack them so that when the first barrier goes down, the second slows the attacker and the third raises an alert. The mistake juniors make is treating one strong control as the answer.
The breach lives in the seam
The dangerous failures are not a missing layer — those get caught in review. They are two correct layers that don’t compose. The hook is the canonical example: authentication answered “yes, this is a valid logged-in user,” and the endpoint trusted that answer to mean “…so let them read the record,” which authentication never claimed. AuthN is not AuthZ. Knowing who someone is tells you nothing about what they may touch.
OWASP ranks Broken Access Control as A01 — the most common serious flaw — precisely because it lives in these seams. The fix is structural, not a patch on one route: enforce authorization in trusted server-side code, deny by default for every non-public resource, and check record ownership on the object the request names, not just the route. An attacker who flips {id} in GET /accounts/{id}/statements is doing IDOR; the only thing that stops it is the server asking “does this subject own this object?” on every request.
| Layer | Looks correct alone | The seam that still breaks it |
|---|---|---|
| Auth (JWT) | Signed, short-lived, verified | Endpoint never checks owns(subject, object) → IDOR |
| Password hashing | Argon2id, salted, ~250ms | Reset endpoint has no rate limit → account takeover |
| Token storage | Strong, valid JWT | Kept in localStorage → one XSS reads it all |
| CSRF defense | SameSite=Lax cookie | A state-changing GET route bypasses it entirely |
The JWT-in-localStorage seam
The token-storage decision is a pure composition problem, so it deserves a close look. A perfectly minted JWT — signed, short expiry, correct claims — is defeated by where you put it. Store it in localStorage and any successful XSS runs localStorage.getItem('token') and exfiltrates it to the attacker’s server; the token’s own strength is irrelevant once JavaScript can read it. The mitigation is an HttpOnly cookie, which JavaScript cannot touch, so XSS can’t steal it — but cookies are sent automatically cross-site, which reopens CSRF. You close that with SameSite plus a CSRF token. Notice the shape: each fix solves one layer and exposes the seam with the next. There is no single setting that wins; you compose HttpOnly + Secure + SameSite + short TTL, and you still have to kill XSS at the source with output encoding and CSP, because an attacker who can run script in your origin has already moved past the storage question.
Why this works
“We use JWTs, so we’re stateless and safe” conflates two unrelated things. A JWT proves authentication and can carry claims; it says nothing about authorization on a specific object, and it does nothing to defend its own storage. Statelessness is an architecture choice. Resistance to IDOR and XSS are separate controls you still have to build — the token format buys you neither.
Least privilege everywhere, not just at the door
Defense in depth is also about blast radius. When a layer does fail — and the model assumes one will — least privilege decides how far the damage spreads. The vault credential the API uses should grant only the rows and operations that endpoint needs, so a compromised service can’t drain the whole database. The access token should be short-lived so a stolen one expires in minutes, not weeks. The dependency you pulled in should run with the same scrutiny, because a malicious transitive package executes inside your trust boundary. NIST SP 800-63B and the OWASP password guidance set the floor for the auth layer — Argon2id tuned to roughly 250–500ms per hash, or bcrypt at cost factor 12+, with a unique per-user salt — but a strong hash behind an unrate-limited reset endpoint still falls. The number on one layer never excuses a gap in the next.
A logged-in user calls `GET /accounts/{id}/statements` with a valid, signed JWT but an id that isn't theirs. What actually stops the breach?
A strong, signed JWT is stored in localStorage. What is the realistic way it gets stolen?
What does 'defense in depth' fundamentally assume about your controls?
Order the layers a single sensitive request passes through, outermost to innermost:
- 1 Transport: TLS + HSTS so the request can't be read or downgraded
- 2 Authentication: prove who is calling (short-lived token, hashed password)
- 3 Authorization: deny-by-default, check this subject owns this object
- 4 Input handling: validate, parameterize, encode every field
- 5 Secrets + dependencies: keys in a vault, packages vetted and pinned
- 01A teammate says the login flow is secure because passwords are Argon2id-hashed and the JWT is strong. Explain why that isn't enough and where the real breach hides.
- 02Walk through why a perfectly valid JWT can still be stolen, and what composing controls looks like in practice.
A single sensitive request passes through layers — transport, authentication, authorization, input handling, secrets, supply chain — and the security track has covered each. The lesson of putting them together is that no one control is sufficient, and the dangerous failures are not missing layers but two correct layers that don’t compose. Authentication proves who is calling and says nothing about whether they may touch a specific object, which is why Broken Access Control is OWASP’s #1 risk: the breach lives in the seam, an endpoint trusting a valid token to mean ownership. A strong Argon2id hash falls behind an unrate-limited reset; a strong JWT is exfiltrated from localStorage by one XSS. Defense in depth assumes every control eventually fails, so you stack independent layers, deny by default, apply least privilege to credentials and token lifetimes, and enforce authorization on the object — not just the identity — on every request. Threat-model the whole flow, not each control alone, because the gap between two correct controls is exactly where attackers live.