Sweaty Imposter

Security

23 scenarios grouped by topic, each with the right move and why the common mistakes fail.

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 today?

The Right Move

argon2id with a per-user salt and tuned memory/time costs

argon2id is the current OWASP 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.

Dive Deeper

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 API on the same parent domain. You need to persist the user's session token across page reloads. Where should the access token live?

The Right Move

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. 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.

Dive Deeper

Pair the cookie with SameSite=Lax (or Strict) and a CSRF 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?

The Right Move

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 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.

Dive Deeper

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)?

The Right Move

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.

Dive Deeper

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.

A page greets the user with a name they typed into their profile: element.innerHTML = 'Hi, ' + user.name. A user sets their name to <img src=x onerror=alert(1)>. What is the simplest safe fix?

The Right Move

Set element.textContent instead, so the browser treats the name as plain text

innerHTML parses its input as HTML, so the injected img tag runs its onerror handler for every visitor who sees the name: a stored XSS bug. textContent inserts the string as text, and nothing in it can execute. Stripping the word script (b) misses this payload entirely, since it uses an event handler. Quotes (c) change nothing about how the HTML is parsed.

Dive Deeper

Frameworks like React escape text by default ({user.name} in JSX is safe), so XSS in those apps usually comes through escape hatches like dangerouslySetInnerHTML or v-html. When you truly need user-supplied HTML, run it through a sanitizer such as DOMPurify first, and add a Content-Security-Policy as defense in depth.

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?

The Right Move

Use a parameterized query and pass q as a bound parameter

Parameterized queries send the SQL 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.

Dive Deeper

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?

The Right Move

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.

Dive Deeper

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?

The Right Move

Attach an IAM instance profile (or IRSA for EKS) so the SDK fetches short-lived creds

Instance profiles (IAM) 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.

Dive Deeper

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.

You pushed a commit to a public GitHub repo, and a minute later you notice it contains a live API key for your payment provider. What do you do first?

The Right Move

Revoke or rotate the key with the provider, then clean it out of the repo history

Assume the key is already compromised: bots scan public GitHub for credentials within minutes of a push. Revoking it is the only step that stops it from working, and cleaning history comes after. Deleting it in a new commit (a) leaves the key in history. A force push (c) or a private repo (d) can't recall the copies that forks, clones, and scrapers already have.

Dive Deeper

Move the new key into an environment variable or a secrets manager and keep config files with secrets in .gitignore. Turn on secret scanning and push protection for the repository, and add a pre-commit scanner such as gitleaks so the next leak is caught before it leaves your machine. Check the provider's logs for any use of the old key while it was exposed.

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 posture makes sense?

The Right Move

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.

Dive Deeper

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 blobs (user notes) at rest in the database. You will manage the keys via KMS. Which symmetric mode should you reach for?

The Right Move

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.

Dive Deeper

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?

The Right Move

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 fragment, exposes them to history/logs, and has been deprecated by the OAuth 2.1 BCPs.

Dive Deeper

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?

The Right Move

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.

Dive Deeper

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?

The Right Move

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 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.

Dive Deeper

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?

The Right Move

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.

Dive Deeper

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?

The Right Move

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.

Dive Deeper

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.

Your signup form checks in JavaScript that the username is 3 to 20 letters and digits before it submits. A teammate says the API can skip its own check because the form already validates. Is that safe?

The Right Move

No, anyone can call the API directly, so the server must validate every request

Client-side checks are a convenience for honest users. Anyone can send a request with curl, a script, or the browser's dev tools and skip the form entirely, so the server has to enforce every rule itself. Minifying (b) slows nobody down. HTTPS (d) protects the request in transit and says nothing about what is in it.

Dive Deeper

Validate on the server with an allowlist that describes a valid username, which holds up better than a blocklist of bad characters, and return a 400 with a clear reason. Keep the client check too, since it gives instant feedback. A database constraint on length and uniqueness is a good last line behind the API check.

Session management

After a user changes their password from the settings page, what should happen to their other active sessions on other devices?

The Right Move

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.

Dive Deeper

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.

Transport security

Your marketing site is served over plain HTTP. Its login form posts to an HTTPS endpoint, so a teammate says passwords are already encrypted. What is wrong with that reasoning?

The Right Move

Anyone on the network can rewrite the HTTP page before it loads, so the page itself must be served over HTTPS

The form's HTML arrives over HTTP, so anyone on the path, like the operator of a coffee shop Wi-Fi, can rewrite it before it renders: change where the form posts, or inject a script that reads the password as it is typed. A password is only as safe as the page that collects it. Browsers allow the post (b), which is why this mistake ships. Hashing in JavaScript (c) turns the hash into the password, and an attacker who can edit the page can remove that code anyway.

Dive Deeper

Serve the whole site over HTTPS, redirect HTTP to HTTPS, and send a Strict-Transport-Security header so browsers stop trying plain HTTP for your domain after the first visit. Mark session cookies Secure so they are never sent over an unencrypted connection.

Account enumeration

Your Forgot Password form says 'No account found with that email' for unknown addresses and 'Check your inbox' for known ones. Why would a security review flag this?

The Right Move

It lets anyone check which emails have accounts, so show the same message either way

Different responses for known and unknown emails let an attacker run a list of addresses through the form and learn who has an account, which feeds phishing and credential stuffing. Return the same message ('If an account exists, we sent a link') either way. Security questions (c) are weaker than an emailed link, since the answers are often public or guessable. Link expiry (d) is good practice and does nothing about the leak.

Dive Deeper

Response time can leak the same thing: if known emails take longer because you create a token and send mail, do that work in a background job so both paths return at the same speed. Rate-limit the endpoint so it can't be used to test thousands of addresses, and apply the same care to login errors.

Error handling

When a request fails in production, your API returns the full stack trace and the SQL query that failed in the response body. It makes debugging easy. What should the API return instead?

The Right Move

A generic error message with a request ID, and the stack trace in server logs

Stack traces and queries tell an attacker your framework, library versions, file paths, and table names, which makes the next attack much easier. Return a generic message with a request ID the user can quote to support, and log the full detail on the server where you can look it up by that ID. Logged-in users (a) include attackers with free accounts. A 200 (b) breaks clients and monitoring. Base64 (d) is an encoding anyone can reverse.

Dive Deeper

Most frameworks have a debug or development mode that renders detailed error pages; make sure it is off in production builds. Keep status codes meaningful (400 for bad input, 404, 500) so clients can react, and keep the internals out of the body.

Authorization

The endpoint DELETE /admin/users/:id checks that the request has a valid session, then deletes the user. A regular logged-in user finds the URL and deletes someone else's account. What is missing?

The Right Move

An authorization check that the caller has the admin role before the delete runs

Authentication answers who the caller is, and the endpoint got that right. Authorization answers what the caller is allowed to do, and the endpoint never asked. Every admin route needs a server-side role check. Hidden URLs (c) turn up in JavaScript bundles, logs, and guesses. A confirmation dialog (d) lives in the UI, and the attacker calls the API directly.

Dive Deeper

Put the role check in shared middleware or a route guard so new admin routes get it by default, and deny by default when a route has no rule. Add a test that calls each admin route as a regular user and expects 403. This bug is broken function level authorization; the per-object version, where one user reads another user's invoice, is BOLA.

Dependencies

npm audit reports a critical vulnerability in a package your app pulls in through another dependency. Your own code never imports it. What should you do?

The Right Move

Upgrade to a version that pulls in the patched package (or add an override), then run the tests

Transitive dependencies run inside your app just like direct ones, and the package that pulled it in may call the vulnerable code for you. Upgrade the parent package to a release that ships the fix, or use the overrides field in package.json to force the patched version, then run your tests. Deleting the lockfile (b) gives you an unpredictable set of upgrades and may still leave the bad version. Turning off the audit (c) hides the problem.

Dive Deeper

npm audit fix applies compatible upgrades for you; review the lockfile diff before merging. If no fix exists yet, read the advisory to see whether the vulnerable path is reachable in your app, write down the decision, and revisit it. Dependabot or Renovate can open upgrade pull requests so these don't pile up.