Cheat sheet · Security for Developers: Web App Security From Zero
Security for Developers cheat sheet
The attacks and the fixes for a web app, from injection and XSS to passwords, secrets, supply chain and incidents. One rule runs through all of it: everything from outside your code is untrusted, so keep data away from code.
blogs · jp-blogs.vercel.app/cheatsheets/series/security
The security mindsetLesson 0 →
- Attackers send the unexpected: a fourth dropdown value,
/notes/18, a fakeX-User-Id, a huge input. - Defence in depth: layers make one bug smaller. A read-only DB user stopped writes despite injectable SQL.
- Least privilege and fail closed: smallest power that works, and an error means no.
- STRIDE: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation.
- Threat model: what are we building, what can go wrong, what will we do, did we do enough.
OWASP Top 10:2025Lesson 0 →
| # | Category |
|---|---|
| A01 | Broken Access Control (includes SSRF, CSRF) |
| A02 | Security Misconfiguration |
| A03 | Software Supply Chain Failures |
| A04 | Cryptographic Failures |
| A05 | Injection (SQL, OS, NoSQL, XSS) |
| A06 | Insecure Design |
| A07 | Authentication Failures |
| A08 | Software or Data Integrity Failures |
| A09 | Security Logging & Alerting Failures |
| A10 | Mishandling of Exceptional Conditions |
Watch out: Name the edition. "A03 is Injection" was the 2021 numbering. In 2025 Injection is A05.
Injection: never mix data into codeLesson 1 →
// BAD: name = admin'-- logs in as admin
`... WHERE name = '${name}'`
// GOOD: SQL and values travel separately
db.prepare(
"SELECT id FROM users
WHERE name = ? AND pw = ?"
).get(name, pw);Escaping quotes is a weak fix. Use the interface made to carry data.
| Interpreter | Attack | Data channel |
|---|---|---|
| SQL | admin'--, ' OR '1'='1, UNION | ? parameters |
| Shell | Sam; echo INJECTED | execFile(prog, [args]), -- before values |
| MongoDB | {"password":{"$ne":null}} | Check typeof is string at the edge |
| Sort column | ?sort=name; DROP... | Allow-list, Object.hasOwn |
- A plain-object lookup of
constructorreturns an inherited function. UseObject.hasOwnor aMap. execruns;|&&.execFiledoes not, but-lacan still be an option. Nevershell: true.
XSS and CSPLesson 2 →
- Reflected: payload in the link. Stored: saved, hits every visitor. DOM: page JS writes it, e.g.
innerHTML = location.hash. - Fix: encode output for the place it lands (HTML text, attribute, JS string, URL, CSS each differ).
<becomes<. HttpOnlyhides the cookie, but the script can still act as the user. It reduces damage only.
| Framework | Safe by default | The hole |
|---|---|---|
| React | {value} | dangerouslySetInnerHTML |
| Vue | {{ value }} | v-html |
| Plain JS | textContent | innerHTML |
- default-src 'self'
- Load things from my origin only
- script-src 'self'
- No inline scripts. Use a nonce or hash to allow one
- frame-ancestors 'none'
- Nobody may frame the page
- Content-Security-Policy-Report-Only
- Log what would break, block nothing
Watch out: CSP is a net, not a cure. 'unsafe-inline' or https: wildcards give almost no protection.
CSRF, clickjacking, headersLesson 2 →
- CSRF: the browser attaches your cookie to a request another site started. The attacker cannot read the reply, only trigger the action.
- Defence: explicit
SameSite, a random session-bound token, checkOriginorSec-Fetch-Site. GET never changes data.
| SameSite | Cookie sent when |
|---|---|
Strict | Request starts on your own site only |
Lax | Also top-level link navigation (GET). Not cross-site POST |
None | Always (needs Secure) |
- frame-ancestors / X-Frame-Options
- Stop clickjacking (Chromium:
ERR_BLOCKED_BY_RESPONSE) - Strict-Transport-Security
- HTTPS only. Only send over HTTPS
- X-Content-Type-Options: nosniff
- No file-type guessing
Tip: helmet() sets a sensible group. Cookie flags are yours. Check the real response with curl -I.
Storing passwordsLesson 3 →
- Never store or encrypt the password. Store a slow, salted hash. A salt is random, per user, not secret.
- SHA-256 is fast: about 330,000 guesses per second on one JS thread. Same password gave the same hash for alice and dave.
- Same error for every login failure: "Email or password is incorrect". Run a dummy hash for unknown users.
| Function | OWASP minimum |
|---|---|
| Argon2id (first choice) | 19 MiB memory, 2 iterations, 1 parallelism |
| scrypt | N=2^17, r=8, p=1. Set maxmem or Node throws |
| bcrypt (legacy) | Work factor 10+, 72 byte limit |
| PBKDF2 (FIPS) | 600,000+ iterations, HMAC-SHA-256 |
Tip: Rules that help: 8+ chars with MFA, allow 64+, any character, block breached passwords, no forced rotation.
Sessions and JWTLesson 3 →
Set-Cookie: __Host-sid=<randomBytes(32)>;
Path=/; Secure; HttpOnly;
SameSite=Lax; Max-Age=3600New ID at login (stops fixation). Idle and absolute timeouts. Never in localStorage.
__Host-forcesSecure,Path=/, noDomain.Securedoes nothing if the site also serves plain HTTP: redirect and use HSTS.- A JWT payload is base64url, readable by anyone. The signature only stops edits. No secrets inside.
- Verify: pin the algorithm in code (reject
alg: none), requireexp, checkissandaud, use a 32+ byte secret.
Reset, MFA and guessing limitsLesson 3 →
- Same message whether the email exists or not.
- Token: 32 random bytes, store only its SHA-256, single use, about 15 minutes.
- On use: mark used, end other sessions, email "password changed". Never email the password.
| MFA method | Weak spot |
|---|---|
| TOTP app (RFC 6238) | Phishable within 30 s. Reject a replayed code |
| SMS or call | SIM swap. OWASP: restricted |
| Passkey / FIDO2 | Phishing resistant. Needs recovery plan |
| Recovery codes | Single use, stored hashed |
- Rate limit per account and per IP. Prefer growing delays to hard locks. Check the limiter before hashing.
- Credential stuffing: breached-password block, MFA, one error text, alert on failure spikes.
Access control (IDOR)Lesson 4 →
-- owner id comes from the SESSION
SELECT * FROM notes
WHERE id = $1 AND owner_id = $2;Login passed but nobody asked "is it yours?" That is an IDOR (BOLA in APIs).
- Check every method (read, update, delete, export), on the server, in one central layer. Hidden buttons are not security.
- Deny by default: no matching rule means no (
?? false). Random UUIDs are a bonus, not the lock. - Mass assignment:
Object.assign(me, body)letsrole: adminin. Allow-list fields or use a DTO, and keep the owner check.
| Code | Use when |
|---|---|
| 401 | Not logged in |
| 403 | Known user, no. Existence is public |
| 404 | Existence is private: "not yours" looks like "not there" |
Crypto, randomness, TLSLesson 5 →
| Tool | Key? | Reversible? | Use |
|---|---|---|---|
| Hash | No | No | Fingerprint, slow hash for passwords |
| Encoding | No | Yes, anyone | base64 is not security |
| HMAC | Yes | No | Webhook signs, JWT HS256 |
| Encryption | Yes | With key | AES-256-GCM for data to read back |
- GCM: new random 12-byte IV every time, store IV + 16-byte tag + ciphertext. A flipped bit throws.
- Secrets use
randomBytes,randomUUID,randomInt. NeverMath.random. - TLS gives identity, privacy, integrity. Never set
rejectUnauthorized: false. HSTS: shortmax-agefirst.
A secret leaked: what nowLesson 5 →
- Rotate now: new key, deploy, revoke the old one.
- Check the provider logs and billing for use.
- Remove from code, load from a secret store.
- Add scanning and push protection.
- Deleting
.envin the next commit changes the latest snapshot only.git log -pstill shows it. NEXT_PUBLIC_values are inlined into the browser bundle. Public by design.
Dependencies and CILesson 6 →
- npm ci
- Exact lock file tree. Fails if out of sync
- ignore-scripts=true
- In
.npmrc: no silentpostinstall - npm audit --audit-level=high
- Exit 1 on high or critical. Known issues only
- Entry routes: typosquatting, install scripts, account takeover, new owner (event-stream), dependency confusion (use
@scope/). - One
npm install expressbrought 65 packages. Read lock file diffs for oddresolvedhosts.
permissions:
contents: read
# pin to a full SHA, not a tag
- uses: actions/checkout@<40-char-sha>
with: { persist-credentials: false }
# untrusted text via env, not run:
- env:
TITLE: ${{ github.event.pr.title }}
run: echo "$TITLE"Avoid pull_request_target with PR code. tj-actions moved its tags in 2025.
SSRF and file uploadsLesson 7 →
- SSRF: your server fetches an attacker URL and reaches
127.0.0.1, internal hosts, or169.254.169.254(cloud metadata). - An allow-list still leaked through a redirect. Use
redirect: "manual"or re-check each hop. Allow onlyhttp:andhttps:. - Open URLs: resolve, then check the IP, inside the connection's own lookup (stops DNS rebinding).
::ffff:7f00:1is loopback.
| Upload risk | Defence |
|---|---|
../victim.txt name | Generate the name (randomUUID() + ext). Check startsWith(dir + sep) |
shell.js | Extension allow-list, 415 otherwise |
Fake Content-Type | Ignore it. Check magic bytes. Re-encode images |
| Huge body | Stop reading at the limit, 413. Set proxy limit, quota |
Errors, CORS, rate limitsLesson 7 →
- Errors: log the stack under an error ID, return only
{ error, errorId }. Debug off in production. Do not fail open. - Limiter: return 429 with
Retry-After. In-memory counts are per process, so use Redis or the gateway. Limit sizes too (?limit=). - CORS is a browser rule that relaxes same-origin. It is not authentication and does not stop CSRF.
| CORS mistake | Why bad |
|---|---|
Reflect Origin + credentials | Any site reads logged-in data |
Allow null | Sandboxed pages send it |
endsWith("example.com") | evilexample.com passes. Compare exactly |
Tip: OWASP API Top 10: API1 BOLA, API3 field level, API5 function level, API4 no limits, API7 SSRF.
Review, scanners, incidentLesson 8 →
| Scanner | Misses |
|---|---|
| SAST (Semgrep, CodeQL) | A missing owner check: no pattern to match |
| DAST (OWASP ZAP) | Code no request reaches; logic bugs |
SCA (npm audit) | Brand-new malicious versions |
| Secrets (gitleaks) | Shapeless secrets. gitleaks git scans history |
- Declare, name one lead and a note-taker.
- Contain: revoke, disable, roll back. Do not delete evidence.
- Rotate every secret that may be exposed.
- Save logs and snapshots with times.
- Scope: first bad event, what was touched.
- Tell owners (GDPR can mean 72 hours), then fix with a test.
Tip: In review, hunt new doors (input, route, outbound call, package) and deleted checks. Write the test with the fix.
Interview one-linersLesson 0 →
| Question | Short answer |
|---|---|
| SQL injection fix? | Parameterised queries; allow-list names; least-privilege DB user. |
| Why does CSRF work? | Cookie is sent whoever starts the request. SameSite plus token. |
| Store passwords? | Argon2id or scrypt, unique salt, tuned cost. |
| Do UUIDs fix IDOR? | No. Check ownership on the server. |
| Key committed? | Rotate first, check logs, then clean up. |
| We use HTTPS, so safe? | No. It protects the pipe, not the endpoints. |
A cheat sheet is a reminder, not a teacher. Each card links to the lesson that explains it step by step.