Back to Cheat Sheets

🔐 Implement Access Control

OWASP Top 10 Proactive Controls (2024) - C1

CORE CONTROL

📋 What Is This Control?

Implement Access Control is the proactive practice of deciding, for every attempted operation, whether the authenticated subject is permitted to perform that action on that specific resource. Authentication answers "who are you?"; access control answers "are you allowed to do this?" - and it must run server-side, on every request, against the specific record. It operationalizes least privilege and deny-by-default, and it is the primary defense against the Broken Access Control risk (A01).

C1 Proactive Control
Authorization Focus Area
OWASP 2024 Proactive Controls

🎯 What It Prevents

  • IDOR / BOLA: Reading another user's record by swapping an identifier
  • Function-Level Escalation: A standard user calling an admin endpoint directly
  • Horizontal Escalation: Cross-tenant access via a supplied account_id
  • Parameter / Metadata Tampering: Trusting a client-sent role=admin
  • Forced Browsing: Reaching unlinked, staging, or legacy endpoints
  • Direct File Access: Guessing a predictable path to a stored file
  • Unchecked Write Verbs: DELETE/PUT on a non-owned resource
  • Undetected Enumeration: Probing IDs at scale with no alerting

🧭 How It Works

1. Authenticate the subject (session/token)
↓
2. Load the specific target resource
↓
3. Ask the central policy: may THIS subject do THIS action on THIS resource?
↓
4. Default answer is DENY - allow only on explicit grant
↓
5. Log the decision; the ownership check stops IDOR & escalation

❌ Without This Control

// Bad: authenticated, but no ownership check (IDOR) app.get('/api/invoices/:id', requireAuth, async (req, res) => { const invoice = await db.invoices.findById(req.params.id); res.json(invoice); ← any logged-in user reads ANY invoice }); // GET /api/invoices/1044 with user A's token returns user B's invoice // Bad: admin function open, link merely hidden in the UI @app.route('/api/admin/users/<uid>/role', methods=['POST']) @login_required def set_role(uid): db.users.update(uid, role=request.json['role']) ← no server-side role check return jsonify(status='ok') // Bad: authority trusted from the request body (metadata tampering) const role = req.body.role; ← attacker sets role=admin if (role === 'admin') { grantAdmin(); } // Bad: static files served straight from a public directory app.use('/uploads', express.static('/var/data/uploads')); // GET /uploads/patient-7312-scan.pdf → streamed with no ownership check

✅ Applying the Control

// Good: record-level ownership check, authority from the session app.get('/api/invoices/:id', requireAuth, async (req, res) => { const invoice = await db.invoices.findById(req.params.id); if (!invoice) return res.status(404).end(); const isOwner = invoice.ownerId === req.user.id; if (!isOwner && req.user.role !== 'admin') { log.warn('authz_denied', { user: req.user.id, invoice: invoice.id }); return res.status(403).end(); ← non-owner is refused } res.json(invoice); }); # Good: centralized, server-side function gate (deny by default) PERMISSIONS = {'user': {'profile:edit'}, 'admin': {'profile:edit', 'user:manage'}} @app.route('/api/admin/users/<uid>/role', methods=['POST']) @login_required @require_permission('user:manage') # authority looked up from trusted state def set_role(uid): db.users.update(uid, role=request.json['role']) return jsonify(status='ok') # Good: scope the query to the owner - a foreign id yields 404, never data def get_order(request, order_id): order = get_object_or_404(Order, id=order_id, owner=request.user) return JsonResponse(serialize(order)) // Good: files outside web root, delivered through an authorizing handler app.get('/files/:id', requireAuth, async (req, res) => { const file = await db.files.findOne({ id: req.params.id, ownerId: req.user.id }); if (!file) return res.status(404).end(); ← not owned ⇒ not found res.sendFile(file.securePath, { root: '/var/data/private' }); });

✓ Implementation Checklist

  • Default decision is deny; access requires an explicit grant
  • Every decision is made server-side; no authority from client input
  • Authorization is centralized in one reviewable component
  • Every object reference has a record-level ownership/tenant check
  • The ownership check covers every verb (read, create, update, delete)
  • Least privilege: new subjects default low; elevation is scoped and revocable
  • Sensitive functions gated by a server-side check, not the UI
  • Protected files served through an authorizing handler
  • Token claims verified; permissions re-checked against server state
  • Access-control failures are logged and alerted on
  • Policy is auditable and covered by negative tests

🔍 Tools & Techniques

Authorization Models & Engines:

RBAC ABAC ReBAC OPA / Rego OpenFGA Oso Spring Security Casbin

Testing & Review:

Burp Suite (Autorize) OWASP ZAP OWASP ASVS

How to Verify:

  • Test that a non-owner is denied on every protected resource (403/404)
  • Test that a lower-privilege user cannot call admin endpoints
  • Confirm a client-supplied role in the body is ignored
  • Probe for IDOR by incrementing/swapping identifiers
  • Forced-browse unlinked and staging endpoints
  • Verify DELETE/PUT are authorized, not just GET

🌍 Why It Matters

  • Broken Object-Level Authorization (IDOR/BOLA): Serving a record by id with no owner check recurs across banking, healthcare, e-commerce, and social platforms; a record-level ownership check stops it.
  • Privilege escalation via unprotected function: An admin action reachable because the UI hid the link - closed by a deny-by-default server-side gate.
  • Forced browsing to unlinked resources: When obscurity is the only barrier, discovery equals access; enforced authorization on the resource itself is the fix.
  • Metadata / parameter tampering: A request carrying its own authority (role=admin) is defeated by deriving authority solely from server-side state.

These are documented vulnerability classes; OWASP ranks Broken Access Control the #1 web application risk. Implementing C1 well drives that risk toward zero.

📌 Quick Tips

  • DO NOT read role/account_id from client input for a decision
  • DO NOT rely on hidden UI as a control
  • DO NOT treat an unguessable ID as access control
  • DO check record-level ownership on every reference
  • DO centralize the authorization decision
  • DO apply the check to every verb
  • DO log denials and test the negative cases