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.

IDOR and object-level authorization

Insecure Direct Object Reference is the canonical case. The endpoint authenticates the user, then trusts an identifier from the request to pick the record.

# Vulnerable: any logged-in user can read any invoice by changing the id
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
    return jsonify(Invoice.query.get(invoice_id).to_dict())

The fix is to scope the query by the authenticated principal, so an id the user does not own simply does not exist.

# Fixed: ownership is part of the query, not a separate check
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
    inv = Invoice.query.filter_by(id=invoice_id,
                                 org_id=current_user.org_id).first_or_404()
    return jsonify(inv.to_dict())

Scoping beats a separate if because it cannot be forgotten on a later code path, and it returns 404 rather than 403, which does not confirm that the record exists.

ApproachVerdict
Filter the query by owner or tenantCorrect, and hard to bypass later
Load, then if obj.owner != user: 403Correct but fragile, easy to omit
Use a UUID instead of a sequential idNot a fix. Harder to guess is not authorized. Ids leak through logs, emails, and other endpoints.
Hide the link in the UINot a fix. The endpoint is the boundary.
Check on the clientNot a fix. The client is attacker controlled.

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 trustBecause
A hidden form field like role=adminThe client edits it
A userId in the request bodyThe client edits it
A JWT claim you did not verifyUnverified claims are user input, check the signature and the algorithm
A cookie such as is_admin=1Set the value server side, or derive it from the session
A previous step in a wizardStep 3 must re-check what step 1 established
The Referer headerTrivially 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.

GapWhat to look for
Function-level authzThe GET is checked but the PUT and DELETE on the same resource are not
Bulk and batch endpointsThe per-item check is skipped when items arrive as an array
Mass assignmentModel.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 reportsThe filter is applied in the UI, not in the query
GraphQLAuthorization on the top-level query only, none on field resolvers
Static and generated filesA signed URL that never expires, or a predictable report path
Path traversalUser input joined into a filesystem path, so ../ escapes the directory
Admin and internal routesReachable directly, protected only by an unlinked URL or a header the proxy was supposed to strip
Method and case variantsA framework that routes /Admin or HEAD differently from /admin and GET
CORSA 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.