Web Security Cheatsheet
Testing Checklist
Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Review checklist by class
Run this against a diff or a feature. One line each, and the corresponding topic in this reference has the detail.
Input and injection
- [ ] Every SQL query uses bind parameters, and no placeholder is quoted.
- [ ] SQL identifiers come from a hardcoded map, never from input.
- [ ] Shell calls pass an argv array with no shell interpretation.
- [ ] No template, regex, or filesystem path is built from user input.
- [ ] NoSQL query values are type-coerced before use.
- [ ] XML parsers have DTD and external entity processing disabled.
Output and XSS
- [ ] Template auto-escaping is on, and each opt-out has a sanitizer and a comment.
- [ ] No untrusted value lands in
<script>,style, or anon*attribute. - [ ] Every template attribute is quoted.
- [ ]
hrefandsrcvalues are scheme-validated, not just encoded. - [ ] A nonce-based CSP is enforced with a fresh nonce per response.
- [ ]
object-src 'none'andbase-uri 'none'are set.
Access control
- [ ] Identity is derived from the session or a verified token, never the request body.
- [ ] Queries filter by owner or tenant rather than checking after loading.
- [ ] Every method on a resource is protected, not just the one under review.
- [ ] Writable fields are an explicit allowlist, so mass assignment is impossible.
- [ ] Nested ids are verified against their parent.
- [ ] Denials return 404 rather than confirming the object exists.
- [ ] A test asserts that tenant B cannot reach tenant A's object.
Authentication and sessions
- [ ] Passwords use Argon2id, scrypt, bcrypt, or PBKDF2 at current parameters.
- [ ] Session ids regenerate on login and on privilege change.
- [ ] Logout invalidates the session server side.
- [ ] Cookies carry
HttpOnly,Secure,SameSite, and noDomain. - [ ] Login, signup, and reset give identical responses for unknown accounts.
- [ ] Reset tokens are CSPRNG, single use, short lived, and revoke all sessions.
- [ ] Rate limiting exists per account and per IP with backoff.
- [ ] JWT verification pins the algorithm and checks
exp,aud, andiss.
CSRF and cross-origin
- [ ] No state change on
GET,HEAD, orOPTIONS. - [ ] Every mutation validates a token, a custom header, or
Origin. - [ ] Missing
OriginandRefereron a mutation is a rejection. - [ ] CORS uses an exact-match allowlist and sends
Vary: Origin.
SSRF and outbound requests
- [ ] User-supplied URLs are matched against an exact host and scheme allowlist.
- [ ] The address is resolved once, validated, and connected to directly.
- [ ] Redirects are disabled or re-validated per hop.
- [ ] Private, loopback, and link-local ranges are rejected in IPv4 and IPv6.
- [ ] IMDSv2 or the provider equivalent is required, hop limit 1.
- [ ] Network egress from the fetching workload is restricted.
Data, secrets, and crypto
- [ ] No secret in the repo, a client bundle, an image layer, or a log.
- [ ] Encryption is AEAD with a fresh nonce per message.
- [ ] No MD5, SHA-1, ECB, DES, or RC4 in a security path.
- [ ] Tokens, ids, and salts come from a CSPRNG.
- [ ] Secret and MAC comparisons are constant time.
- [ ] Certificate verification is enabled in every client.
Files and dependencies
- [ ] Stored filenames are server-generated and extensions are allowlisted.
- [ ] Content type is detected from bytes and cross-checked with the extension.
- [ ] Files are served from a separate origin with
attachmentandnosniff. - [ ] No untrusted input reaches
pickle,yaml.load,readObject, orunserialize. - [ ] Dependencies are pinned with hashes and scanned in CI.
Configuration and observability
- [ ] Security headers are set at the edge and present on error responses.
- [ ] Debug mode, stack traces, and directory listings are off in production.
- [ ] Version banners are removed.
- [ ] Authenticated responses send Cache-Control: no-store.
- [ ] Authorization denials, auth failures, and admin actions are logged with a
request id and no secrets, and something alerts on a spike.
Automate what a tool can find
Tooling catches the mechanical classes and frees review for logic. None of it detects broken access control, which is why the manual checklist above still matters.
| Layer | Purpose | Examples |
|---|---|---|
| SAST | Pattern and dataflow bugs in your code | Semgrep, CodeQL, Bandit, gosec, Brakeman |
| SCA | Known CVEs in dependencies | npm audit, pip-audit, Dependabot, Trivy, osv-scanner |
| Secret scanning | Credentials in history and in diffs | gitleaks, trufflehog, provider push protection |
| Container and IaC | Base image CVEs, bad cloud config | Trivy, Grype, Checkov, tfsec |
| DAST | Runtime behavior against a running app | OWASP ZAP, Nuclei |
| Header check | Response header posture | curl -I, Mozilla Observatory |
| Fuzzing | Parser and boundary robustness | libFuzzer, go-fuzz, Atheris, Jazzer |
# Fast local pass, all read-only semgrep --config=p/owasp-top-ten . gitleaks detect --source . --redact npm audit --omit=dev osv-scanner scan source -r . trivy fs --scanners vuln,secret,misconfig . curl -sI https://staging.example.com | grep -iE 'content-security|strict-transport'
Wire the fast ones into pre-commit and all of them into CI. Fail the build on new high-severity findings rather than on the total count, so an existing backlog does not train everyone to ignore the gate.
Write security tests, not just scans
A scanner tells you about a class of bug once. A test keeps it fixed. These are the highest-value assertions to add, in rough order of return.
def test_cross_tenant_read_is_404(client, user_b, invoice_of_org_a): client.login(user_b) r = client.get(f"/api/invoices/{invoice_of_org_a.id}") assert r.status_code == 404 # not 403, which would confirm existence def test_mutation_without_csrf_token_is_rejected(client, user): client.login(user) r = client.post("/account/email", data={"email": "x@example.com"}) assert r.status_code == 403 def test_role_is_not_mass_assignable(client, user): client.login(user) client.patch("/api/me", json={"name": "ada", "role": "admin"}) assert reload(user).role == "member" def test_session_id_changes_on_login(client, user): before = client.cookie("session") client.login(user) assert client.cookie("session") != before def test_security_headers_present_on_error(client): r = client.get("/does-not-exist") assert r.headers["X-Content-Type-Options"] == "nosniff"
Every fixed vulnerability gets a regression test in the same commit. That is the single habit that keeps a codebase from re-introducing the same bug next quarter.
Practice legally
If you want to build hands-on skill, use environments that exist for it. These are intentionally vulnerable applications you run yourself or platforms that grant permission explicitly.
- OWASP Juice Shop, a modern intentionally vulnerable web app you self-host. - OWASP WebGoat, lesson-based and self-hosted. - PortSwigger Web Security Academy, free labs with written explanations of each class. - OWASP ASVS as the requirements checklist to verify a real application against. - HackerOne and Bugcrowd for programs where a company has published a scope and granted permission in writing.
Read the program's scope and rules before you touch anything, and report what you find through the channel it names.