Auditing and fixing authorization gaps — ensuring every route checks who can access which specific resource, not just whether the caller is logged in.
The Core Distinction
Authentication → "Are you logged in?" (identity)
Authorization → "Can you access THIS record?" (permission)
Most auth middleware only handles the first. The second must be enforced per-endpoint, per-resource.
Vulnerability Patterns
1. IDOR (Insecure Direct Object Reference)
User supplies an ID; server fetches it without checking ownership.
// ❌ Authenticated but not authorized
app.get('/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
res.json(invoice); // any logged-in user gets any invoice
});
// ✅ Ownership check
app.get('/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
owner_id: req.user.id // scope to caller
});
if (!invoice) return res.status(404).send(); // 404, not 403 — don't confirm existence
});
Rule: Never fetch by ID alone. Always include owner_id = req.user.id (or equivalent) in the query.
2. Missing Role Check
Endpoint exists, auth required, but no role/permission gate.
// ❌ Any authenticated user can delete users
app.delete('/admin/users/:id', requireAuth, deleteUser);
// ✅
app.delete('/admin/users/:id', requireAuth, requireRole('admin'), deleteUser);
3. Privilege Escalation via User-Supplied Role
User sends their own role in the request body and it gets written to the DB.
// ❌
app.put('/users/:id', requireAuth, async (req, res) => {
await db.users.update(req.params.id, req.body); // role, plan, credits — all writable
});
// ✅ Explicit field allowlist
app.put('/users/:id', requireAuth, async (req, res) => {
const { name, email, bio } = req.body; // only safe fields
await db.users.update(req.params.id, { name, email, bio });
});
4. Bypassing via HTTP Method
Authorization check tied to a specific method; same route accessible via another.
// ❌ Only GET is gated; POST to same path skips the check
router.get('/reports/:id', requireAuth, getReport);
router.post('/reports/:id', processReport); // forgot auth here
Check every verb on a route independently.
5. Path Traversal / Parameter Manipulation
ID in URL vs. ID in JWT disagree; server trusts the URL.
// ❌ User can PUT /users/999 even if their token says id=1
app.put('/users/:id', requireAuth, async (req, res) => {
await db.users.update(req.params.id, req.body);
});
// ✅ Ignore URL param for self-edit; use token
app.put('/users/me', requireAuth, async (req, res) => {
await db.users.update(req.user.id, sanitize(req.body));
});
6. Nested Resource Without Parent Check
Child resource ownership is checked, but parent isn't.
// ❌ Checks comment ownership but not post ownership
app.delete('/posts/:postId/comments/:commentId', requireAuth, async (req, res) => {
const comment = await db.comments.findOne({ id: req.params.commentId, author_id: req.user.id });
if (!comment) return res.status(404).send();
await comment.delete();
});
// ✅ Verify the post belongs to the user too (if that's the intent)
const post = await db.posts.findOne({ id: req.params.postId, owner_id: req.user.id });
if (!post) return res.status(404).send();
7. Tenant Isolation (Multi-Tenant Apps)
Query scoped to user but not to tenant — user from Org A can read Org B's data.
// ❌ Scoped to user but not tenant
const records = await db.records.findAll({ user_id: req.user.id });
// ✅ Always include tenant scope
const records = await db.records.findAll({
user_id: req.user.id,
tenant_id: req.user.tenant_id // from token, never from request body
});
Authorization Models
Choose one and apply it consistently. See references/models.md for implementation detail.
| Model | Best for | Key concept |
|---|---|---|
| RBAC (Role-Based) | Clear user roles (admin, editor, viewer) | Role → permission set |
| ABAC (Attribute-Based) | Fine-grained, context-sensitive | Policy rules on resource + user attributes |
| Ownership | User-owned resources | resource.owner_id === req.user.id |
| ReBAC (Relation-Based) | Hierarchical resources, sharing | Graph of relationships (e.g. Google Zanzibar) |
Most apps need Ownership + RBAC together: ownership for user data, roles for admin surfaces.
Audit Process
Step 1 — Map Every Route
List all endpoints with their HTTP verb. Flag any with no requireAuth or equivalent middleware.
Step 2 — Classify Each Route
For each authenticated route, determine what check is needed:
| Route type | Required check |
|---|---|
| User's own resource | resource.owner_id === req.user.id |
| Admin operation | req.user.role === 'admin' (or permission bit) |
| Shared/org resource | resource.org_id === req.user.org_id |
| Public read | No auth needed — confirm intentional |
Step 3 — Check the Query
For every DB fetch: does the WHERE clause include the ownership/tenant scope, or does it fetch by ID alone then check in application code?
// Risky — fetch then check (TOCTOU, extra DB round-trip, easy to forget)
const item = await db.findById(id);
if (item.owner !== req.user.id) return res.status(403);
// Better — let the DB enforce it
const item = await db.findOne({ id, owner_id: req.user.id });
if (!item) return res.status(404);
Step 4 — Check Write Paths
Every POST / PUT / PATCH / DELETE: are fields allowlisted? Can a user escalate their own role, balance, or plan via this endpoint?
Step 5 — Check Indirect References
Are there endpoints that accept email, username, or slug instead of ID? Apply the same ownership check — just via a different lookup key.
Response Codes
| Situation | Code | Reason |
|---|---|---|
| Not logged in | 401 | Client should re-authenticate |
| Logged in, wrong permissions | 403 | Client is authenticated but not authorized |
| Resource exists but caller shouldn't know | 404 | Don't leak existence of records |
Use 404 (not 403) when confirming a record's existence would itself be a data leak (e.g., other users' private invoices).
Audit Checklist
- Every route has
requireAuth(or is explicitly marked public) - Every resource fetch includes ownership/tenant scope in the query — not just auth check
- Admin/privileged routes have role/permission checks beyond just
requireAuth - Write endpoints allowlist accepted fields — no
req.bodypassed directly to ORM - No user-supplied
role,plan,credits, or similar fields accepted on update - Nested resources: parent ownership verified before child access
- Multi-tenant:
tenant_idalways in queries, sourced from token not request - All HTTP verbs on a route are gated, not just GET
- URL params not trusted for self-referencing operations (use
req.user.id) - 403 vs 404 used correctly — sensitive resource existence not leaked
Reference Files
references/models.md— RBAC, ABAC, ownership middleware patterns with codereferences/testing.md— How to test for access control bugs (unit, integration, manual)