Security
15 scenarios grouped by topic. Click any to see the right move and why the common mistakes are wrong.
Password storage
You are shipping a brand-new web app and need to pick how to store user passwords. There is no legacy hash format to be compatible with and your servers have modern CPUs. Which approach is the right default in 2025?
argon2id with a per-user salt and tuned memory/time costs
argon2id is the current OWASP (Open Web Application Security Project) recommended password hash: memory-hard, GPU-resistant, with a built-in salt. SHA-256 plus salt is the most tempting distractor but it is a fast hash — even with a salt an attacker with a stolen dump can brute-force billions of guesses per second on a GPU.
If argon2 is not available, bcrypt (work factor 12+) or PBKDF2-HMAC-SHA256 with 600k+ iterations are acceptable fallbacks. Encryption is the wrong primitive entirely: it is reversible, and a leaked key turns every password into plaintext. Always store only the hash plus parameters, never the password.
JWT
A React SPA talks to a REST (Representational State Transfer) API on the same parent domain. You need to persist the user's session token across page reloads. Where should the access token live?
An httpOnly, Secure, SameSite=Lax cookie set by the API
An httpOnly cookie cannot be read by JavaScript, so a single XSS (Cross-Site Scripting) bug cannot exfiltrate the JWT (JSON Web Token). localStorage is the classic wrong answer — it is trivially readable by any script that runs on the page, including injected ones from a compromised dependency.
Pair the cookie with SameSite=Lax (or Strict) and a CSRF (Cross-Site Request Forgery) defense for state-changing requests, since cookies are sent automatically. If you must use a bearer token in a header, keep it in memory only and refresh via a httpOnly refresh-token cookie — never persist it to web storage.
CSRF
Your app authenticates users with a session cookie and exposes POST /transfer to move money. A user reports that visiting a sketchy site while logged in caused a transfer. You confirm the cookie is Secure and httpOnly. What is the minimum fix?
Require a CSRF token (or SameSite=Strict cookie) on state-changing requests
A cookie alone proves the browser has a session but not that your own page initiated the request. A CSRF (Cross-Site Request Forgery) token bound to the session, or SameSite=Strict on the cookie, defeats cross-origin form submissions. CORS is the common wrong answer — it restricts what JS can read, not what a form post can send.
Modern browsers default cookies to SameSite=Lax, which blocks cross-site POSTs but still allows top-level GET navigations, so do not put state-changing logic behind GET. Double-submit cookie or synchronizer-token patterns both work; pick one and enforce it in a single middleware.
XSS
Users can write comments that other users see. Your template engine inserts the raw string into both an HTML body and a JavaScript onclick attribute. How should you defend against XSS (Cross-Site Scripting)?
Apply context-aware output encoding (HTML body vs attribute vs JS) at render time
The same string is dangerous in different ways depending on where it lands: HTML body needs entity encoding, attributes need attribute encoding, JS contexts need JS string escaping. Input sanitization is the tempting distractor but is brittle — encodings, mutation XSS, and new injection contexts routinely bypass blocklists.
Use a templating engine that encodes by default and never disable it for user data. CSP is excellent defense in depth (nonce-based, no unsafe-inline) but it is a second line, not a substitute for correct encoding. Avoid building HTML by string concatenation entirely; prefer DOM APIs or a sanitizer like DOMPurify for rich content.
SQL injection
A search endpoint receives a free-text query and your ORM exposes a raw() escape hatch. A teammate writes db.raw("SELECT * FROM items WHERE name LIKE '%" + q + "%'"). What is the correct fix?
Use a parameterized query and pass q as a bound parameter
Parameterized queries send the SQL (Structured Query Language) and the data on separate channels, so the input can never be parsed as code. Quote escaping is the classic wrong answer — Unicode tricks, backslash handling, and second-order injection all defeat hand-rolled escaping eventually.
For LIKE searches, bind the parameter and escape user-supplied % and _ at the application layer so wildcards behave correctly. Keyword blocklists break legitimate inputs (a product named 'Select Edition') and miss obfuscated payloads. ORMs are safe only when used through their typed query builders or explicit bindings.
Authorization (BOLA/IDOR)
GET /api/invoices/{id} returns an invoice. The handler looks up the invoice by id and returns it. Audit logs show user 42 successfully fetched invoice 9001, which belongs to user 77. What is missing?
An authorization check that the invoice's owner equals the caller's user id
This is a textbook BOLA (Broken Object Level Authorization) / IDOR (Insecure Direct Object Reference): authentication confirms who the caller is, but the handler never checks that the caller is allowed to see that specific object. UUIDs are the seductive wrong answer — they make guessing harder but ids leak through referrers, screenshots, and shared links, so they are obscurity, not authorization.
Centralize authorization in a policy layer (e.g. scoped query: SELECT ... WHERE id = ? AND owner_id = currentUser) so every read and write enforces the rule. Add automated tests where user A tries to access user B's resources and expects 404 (not 403, which confirms existence).
Secrets management
Your service runs on an EC2 instance and needs to read objects from S3. A junior dev plans to bake AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into the container image as environment variables. What should you do instead?
Attach an IAM instance profile (or IRSA for EKS) so the SDK fetches short-lived creds
Instance profiles (IAM (Identity and Access Management)) and IRSA on EKS deliver rotating, short-lived credentials via the metadata service — the SDK picks them up automatically and there is nothing to leak. ConfigMaps are the tempting wrong answer, but they store values in plaintext and end up in etcd, kubectl describe, and CI logs.
For workloads outside AWS, use OIDC federation to assume a role with no static keys at all. If you truly must use long-lived keys, store them in Secrets Manager or Vault, scope the IAM policy tightly, and enable rotation. Never commit credentials, even to a private repo — assume every repo will leak eventually.
TLS
You are shipping a banking iOS app that talks to api.bank.example over HTTPS. The threat model includes users on hostile Wi-Fi and the possibility of a compromised public CA. What TLS (Transport Layer Security) posture makes sense?
Pin the server's public key (SPKI) with a backup pin, plus normal CA validation
SPKI pinning survives certificate rotation as long as the key stays the same, and a backup pin keeps you from bricking the app if you need to rotate keys. Leaf-cert pinning is the plausible wrong answer — it works but requires an app update on every renewal, which is operationally fragile and tempts teams to use very long-lived certs.
Always combine pinning with regular CA validation so a misconfigured pin does not silently accept a self-signed cert. Plan a kill-switch (remote config) so you can disable pinning if a key compromise forces an emergency rotation faster than App Store review allows.
Crypto choice
You need to encrypt small JSON (JavaScript Object Notation) blobs (user notes) at rest in the database. You will manage the keys via KMS. Which symmetric mode should you reach for?
AES-256-GCM with a unique nonce per record
AES-GCM is authenticated encryption: it provides both confidentiality and integrity, so a tampered ciphertext is rejected on decrypt. CBC is the tempting distractor because it has a random IV, but it offers no integrity — you need to layer an HMAC (Hash-based Message Authentication Code), and getting the order right (encrypt-then-MAC) is easy to botch.
With GCM, never reuse a (key, nonce) pair — doing so catastrophically breaks both confidentiality and integrity. For random nonces, 96 bits is fine up to ~2^32 messages per key; beyond that, use a deterministic scheme like AES-GCM-SIV. ECB leaks patterns (the famous Tux image) and should be considered unusable for real data.
OAuth/OIDC
You are integrating Sign in with Google into a public SPA that has no backend it can call its own (the SPA talks directly to your API). Which OAuth flow should the SPA use?
Authorization Code with PKCE, no client secret
Auth Code + PKCE is the modern recommendation for public clients: the code_verifier ties the redirect back to the original request, so a stolen auth code is useless. Implicit flow is the obvious-looking wrong answer — it returns tokens in the URL (Uniform Resource Locator) fragment, exposes them to history/logs, and has been deprecated by the OAuth 2.1 BCPs.
Never put a client_secret in a SPA or mobile app — anything shipped to the client is public. ROPC bypasses the IdP's MFA and consent screens and is being removed across providers. After the code exchange, store tokens in memory (or behind an httpOnly cookie if you add a thin backend-for-frontend).
Rate limiting / abuse
Your /login endpoint is getting hammered: credential-stuffing attempts from thousands of residential IPs, sometimes one attempt per IP. You also do not want to lock real users out. What rate-limiting strategy fits?
Per-account and per-IP limits with exponential backoff, plus CAPTCHA after threshold
Layered limits catch both the low-and-slow per-IP attack and the targeted single-account attack, and adaptive friction (CAPTCHA, delay) raises cost without a hard lock. Per-account lockout is the seductive wrong answer because it enables trivial denial-of-service: an attacker can lock any user out by submitting wrong passwords.
Track failed attempts by (account, IP) and (account) separately, and use stable identifiers (email hash) so attackers cannot evade by varying case or whitespace. Alert on a spike in unique accounts attempted from one ASN — that is the signature of stuffing. Always keep error messages identical for unknown user vs wrong password to avoid enumeration.
Logging & PII
A teammate adds middleware that logs every request and response body to make debugging easier in production. Endpoints include /login, /payments, and /users/me. What is your review feedback?
Reject — bodies contain passwords, tokens, and PII; redact or do not log them
Full request/response bodies pull passwords, session tokens, payment data, and PII (Personally Identifiable Information) into a system that was not designed as a vault — and once data is in logs it tends to spread to backups, SIEMs, and screenshots. Short retention is the tempting answer but it does not stop the immediate exposure to anyone with log access.
Define an allowlist of safe fields per route or a denylist of sensitive keys (password, token, ssn, card) that the logger redacts before serialization. Log metadata (status, latency, user id, request id) generously; log payloads sparingly and only when scrubbed. Treat log access as a privileged action with audit trails of its own.
Multi-tenant isolation
Your SaaS stores all tenants in one Postgres database with a tenant_id column on every table. A new engineer is writing queries and forgets the tenant filter on a JOIN, leaking data across tenants in staging. How do you prevent this systemically?
Row-level security in Postgres driven by a session variable set per request
Postgres RLS enforces the tenant filter at the database, so a forgotten clause cannot leak data — the policy uses a per-connection setting like current_setting('app.tenant_id'). Code-review checklists are the obvious wrong answer: humans miss things, and the failure mode (silent cross-tenant read) is exactly what you cannot afford to learn about in prod.
Set the tenant id once at connection check-out (via SET LOCAL) and have RLS policies reference it. Pair with a denylist of superuser roles in app code and integration tests where requests authenticated as tenant A try to read tenant B and must get zero rows. For very high-isolation tenants, escalate to schema-per-tenant or DB-per-tenant.
Input validation
An /upload endpoint accepts a file and a JSON metadata blob. The metadata includes a filename, size, and mime_type that the client computes. The server stores the file under filename and trusts mime_type for Content-Type on later downloads. What is the safer design?
Validate filename against an allowlist regex and detect mime type server-side from file bytes
Never trust client-declared content type — an attacker can upload an HTML file labeled image/png and trigger stored XSS when another user views it. Sniffing the type from the bytes and constraining filenames to a known-safe charset closes both holes. Rejecting only .. is the plausible wrong answer; it misses null bytes, absolute paths, Unicode normalization tricks, and reserved Windows names.
Store uploads under a server-generated id (UUID) and keep the original filename only as metadata. Serve downloads with Content-Disposition: attachment and a strict Content-Type, ideally from a separate cookieless domain so a stray HTML file cannot ride the user's session. AV scanning is useful defense in depth but does not absolve you of validating types.
Session management
After a user changes their password from the settings page, what should happen to their other active sessions on other devices?
Invalidate all other sessions server-side and force re-authentication
Password change is the primary user signal that 'someone may have my account' — every other session is now potentially an attacker, and the only safe move is to invalidate them server-side. Prompting the user is the friendly-sounding wrong answer; a victim who is panicking may dismiss the dialog, and a stateless 'just rotate this cookie' does nothing about the other tokens already in the wild.
Sessions need a server-side store (or a token version counter on the user row that JWTs include in their claims) so that revocation actually takes effect. Apply the same invalidation on email change, MFA enrollment, and forced password reset. Always keep the current device logged in so the user does not get kicked out of the flow that just succeeded.