Index · How it works
5 min readHow it works
Access control answers the question "is this principal allowed to perform this action on this resource right now?" A break happens when the answer the system gives differs from the answer the policy intends — and an attacker exploits that gap by manipulating identifiers, replaying requests, or simply navigating to URLs they were never linked to.
Authentication vs authorization
These are distinct and are often confused, which is itself a source of bugs.
- Authentication (AuthN) establishes who you are. It answers "is this really Alice?" via passwords, MFA, tokens, certificates.
- Authorization (AuthZ) establishes what you may do. It answers "may Alice read invoice 1044 / delete this user / reach
/admin?"
A system can authenticate perfectly and still be wide open: once Alice is logged in, if the server never re-checks ownership or role on each request, she can reach anything. Authentication is a gate at the door; authorization is the lock on every individual room. Broken Access Control is overwhelmingly a failure of the second, not the first.
IDOR / insecure direct object references
An Insecure Direct Object Reference (IDOR) occurs when the application exposes a reference to an internal object — a database primary key, a filename, a user ID — directly in a request, and authorizes the action based on the existence of that reference rather than the requester's right to it. The attacker simply changes the value.
❌ Bad example
// GET /api/orders/:id
app.get('/api/orders/:id', requireLogin, async (req, res) => {
const order = await db.orders.findById(req.params.id); // no owner check!
res.json(order); // any logged-in user can read ANY order by guessing the id
});
✅ Good example
// GET /api/orders/:id — scope every lookup to the authenticated principal
app.get('/api/orders/:id', requireLogin, async (req, res) => {
const order = await db.orders.findOne({
id: req.params.id,
ownerId: req.user.id, // authorization is part of the query
});
if (!order) return res.sendStatus(404); // 404, not 403, to avoid leaking existence
res.json(order);
});
The decisive move is folding the authorization predicate (ownerId = req.user.id) into the data access itself, so an object the user doesn't own is simply not found. Using opaque/unguessable identifiers (UUIDs) raises the bar but is not a fix — it's defense-in-depth, because IDs leak through logs, referrers, and shared links.
Vertical vs horizontal privilege escalation
- Horizontal privilege escalation — acting as a different user at the same privilege level. Alice reads Bob's invoice. This is the classic IDOR outcome.
- Vertical privilege escalation — acting at a higher privilege level. A regular user invokes an admin-only function:
POST /api/users/42/role {"role":"admin"}, or reaches/admin/dashboard, or flipsisAdmin=truein a request body that the server trusts (mass assignment).
Both stem from missing per-request checks; the difference is only whose or which privilege is crossed. Interviewers love this distinction — name both and give a one-line example of each.
RBAC vs ABAC
Two dominant authorization models:
- RBAC (Role-Based Access Control) — permissions are attached to roles (
admin,editor,viewer) and users are assigned roles. Simple, auditable, coarse-grained. Great for "admins can delete users." Weak when rules depend on the specific resource ("editors can edit only their own department's posts"). - ABAC (Attribute-Based Access Control) — decisions are computed from attributes of the subject, resource, action, and environment (
subject.department == resource.department AND action == 'edit' AND time.business_hours). Fine-grained, expressive, context-aware; harder to reason about and audit.
In practice mature systems use RBAC for coarse function-level gates plus ABAC/relationship checks (often called ReBAC) for fine-grained, ownership/tenant-scoped data access. The key interview point: RBAC alone rarely expresses ownership, which is exactly where IDOR lives.
Deny-by-default & principle of least privilege
- Deny by default: every resource is forbidden unless a rule explicitly permits it. New endpoints, new fields, and forgotten routes are then safe by construction — the opposite of an allow-list-by-omission system where forgetting to add a check exposes data.
- Principle of least privilege (PoLP): every principal (user, service account, token, DB connection) gets the minimum permissions needed and nothing more, ideally time-bounded. A read-only report job should not hold write credentials; a tenant token should not be able to read another tenant's rows.
Together these turn "we forgot to add a check" — the most common real-world cause — from a breach into, at worst, a 403.
Enforcing authz on the server / per-request
Access control must run server-side, on every request, at the resource. Stateless protocols (HTTP) mean each request must independently carry and prove the right to act; there is no "I checked you last time." Centralize the decision (a policy/authorization middleware or service) so it can't be forgotten, but enforce it at the point of data access so it can't be bypassed by an alternate route. Apply checks uniformly across REST, GraphQL resolvers, gRPC, websockets, and batch/admin jobs — attackers find the one transport you forgot.
Common mistakes
- Checks only in the UI — hiding the "Delete" button or the
/adminlink while the underlying endpoint stays open. The button is a UX hint, not a control;curlignores it. - Trusting client-supplied IDs — using
req.params.id/userIdfrom the body as the source of truth instead of deriving identity from the session/token. This is the IDOR engine. - Trusting client-supplied roles — reading
role/isAdminfrom a request body, cookie, or unverified JWT. Roles must come from a trusted server-side store or a cryptographically verified token. - Missing function-level checks — protecting
GET /admin/usersbut forgettingPOST /admin/usersorDELETE /admin/users/:id. Coverage gaps across HTTP verbs and sibling endpoints. - Path traversal —
GET /files?name=../../etc/passwdletting a user escape the intended directory; an access-control failure over the filesystem namespace. Canonicalize and validate paths against an allow-list root. - CORS misconfiguration — reflecting the
Originheader intoAccess-Control-Allow-Origintogether withAllow-Credentials: true, or using*carelessly, lets a malicious site make authenticated cross-origin requests on the victim's behalf. - JWT claim trust — accepting a token without verifying signature/issuer/audience/expiry, allowing the
alg:nonedowngrade, or treating any claim (includingrole,tenant,sub) as authoritative without server-side validation.
Force-browsing & metadata/admin endpoints
Force-browsing (forced browsing) is directly requesting URLs you were never given a link to — /admin, /api/internal/users, /.git/config, /v2/, /actuator, /debug. Attackers enumerate these with wordlists. If the app relies on the URL being "secret" (security through obscurity) rather than on an enforced check, the data is exposed. The same applies to metadata/admin endpoints: framework defaults like Spring Boot Actuator, Swagger/OpenAPI docs, GraphQL introspection, .env/backup files, and cloud instance-metadata services (169.254.169.254) often leak configuration, credentials, or admin functionality when left reachable. Treat every path — discoverable or not — as requiring an explicit authorization decision, and remove or lock down metadata endpoints in production.