Web Security Cheatsheet
Access Control
Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
The number one risk
Broken Access Control has been A01 in both the 2021 and 2025 OWASP Top 10, and in 2025 SSRF was folded into it. Around 3.73% of tested applications had at least one of its 40 associated CWEs. It is common because it cannot be fixed by a library. Authorization is business logic, so every endpoint has to get it right.
Distinguish the two halves clearly, because conflating them is the root cause:
- Authentication is who are you. Solved once, by a framework. - Authorization is may you do this, to this specific object, right now. Solved per request, per object, forever.
Check on the server, every time
The rule, stated plainly: every request re-derives the caller's identity from a trusted source and re-evaluates authorization against the specific object. No step of a flow inherits a decision from a previous step.
| Never trust | Because |
|---|---|
A hidden form field like role=admin | The client edits it |
A userId in the request body | The client edits it |
| A JWT claim you did not verify | Unverified claims are user input, check the signature and the algorithm |
A cookie such as is_admin=1 | Set the value server side, or derive it from the session |
| A previous step in a wizard | Step 3 must re-check what step 1 established |
The Referer header | Trivially set, and often absent |
// Vulnerable: the client supplies the account to act on app.post('/api/transfer', requireLogin, (req, res) => { transfer(req.body.fromAccount, req.body.toAccount, req.body.amount) }) // Fixed: the source account is derived from the session, then ownership verified app.post('/api/transfer', requireLogin, async (req, res) => { const from = await Account.findOne({ id: req.body.fromAccount, ownerId: req.user.id, }) if (!from) return res.sendStatus(404) await transfer(from, req.body.toAccount, req.body.amount) })
Deny by default
An allowlist of what is public is auditable. A denylist of what is protected fails silently the moment someone adds a route.
# Deny by default: unlisted routes require auth PUBLIC = {"/", "/login", "/health", "/static"} @app.before_request def require_auth(): if request.path in PUBLIC or request.path.startswith("/static/"): return if not current_user.is_authenticated: abort(401)
Structural rules that follow from it:
- Enforce at a chokepoint (middleware, a base controller, a policy layer) so a new
endpoint is protected before anyone writes its handler.
- Keep the policy in one place, so "who may do what" is readable in a single file
rather than scattered across 200 handlers.
- Include the tenant in the primary access path for multi-tenant systems. A missing
WHERE org_id = ? is a cross-tenant data leak.
- Log every authorization denial with the principal, the object, and the action.
A spike is the earliest signal you get.
- Rate limit sensitive endpoints, which bounds enumeration when a check does fail.
Where checks get missed
The happy path is usually correct. The gaps cluster in predictable places, which overlaps with A10:2025 Mishandling of Exceptional Conditions.
| Gap | What to look for |
|---|---|
| Function-level authz | The GET is checked but the PUT and DELETE on the same resource are not |
| Bulk and batch endpoints | The per-item check is skipped when items arrive as an array |
| Mass assignment | Model.update(req.body) lets a user set role, is_admin, or balance |
| Nested and expanded resources | /orders/1/items/99 checks the order but not that item 99 belongs to it |
| Search, export, and reports | The filter is applied in the UI, not in the query |
| GraphQL | Authorization on the top-level query only, none on field resolvers |
| Static and generated files | A signed URL that never expires, or a predictable report path |
| Path traversal | User input joined into a filesystem path, so ../ escapes the directory |
| Admin and internal routes | Reachable directly, protected only by an unlinked URL or a header the proxy was supposed to strip |
| Method and case variants | A framework that routes /Admin or HEAD differently from /admin and GET |
| CORS | A permissive policy turns a browser-only endpoint into a cross-origin one |
// Mass assignment, vulnerable await User.update(req.body, { where: { id: req.user.id } }) // Fixed: an explicit allowlist of assignable fields const { name, timezone } = req.body await User.update({ name, timezone }, { where: { id: req.user.id } })
Recognizing it in review
# Handlers that read an id straight from the request grep -rnE "params\.(id|userId)|request\.args\[|req\.body\.(id|userId)" src/ # Queries with no tenant or owner scoping grep -rnE "findByPk\(|\.get\(id\)|findById\(|Objects\.get\(pk=" src/ # Mass assignment grep -rnE "update\(req\.body|\*\*request\.(json|form)|\.assign\(.*body" src/ # Path building from input grep -rnE "path\.join\(.*req\.|os\.path\.join\(.*request" src/
Checklist, one question per endpoint:
1. Is the caller's identity derived from the session or a verified token, never from the request body? 2. Does the query filter by owner or tenant, rather than checking afterwards? 3. Is every method on the resource protected, not just the one you are reading? 4. Are writable fields an explicit allowlist? 5. Does a nested id get verified against its parent? 6. On failure, does it return 404 rather than confirming the object exists? 7. Is the same rule enforced for the API, the admin UI, the export, and the background job? 8. Is there a test that a user from tenant B gets a 404 on tenant A's object? This is the single highest-value security test you can write, because access control flaws are invisible to scanners and only a test that asserts denial catches regressions.
Further reading: the OWASP Authorization Cheat Sheet and A01:2025 Broken Access Control.