Changing an ID in the URL shows another user's data
“Editing /invoices/1042 to /invoices/1043, or swapping an ID in a JSON body, returns or modifies a record that belongs to another user or tenant. The user is authenticated; nothing checks ownership.”
THE PRINCIPLE
An ID from the client is a request, not a permission. Every object lookup carries the caller's identity.
FIRST MOVES
- Test with two accounts in different tenants: read, update, delete, and export each other's objects across every endpoint, including GraphQL resolvers and bulk APIs.
- Scope lookups at the query: where id = ? and tenant_id = ?, or currentUser.projects.find(id), instead of fetching by id and checking later.
- Take the user and tenant from the session, never from request parameters or bodies.
- Return the same 404 for missing and forbidden objects so IDs cannot be probed for existence.
- Centralize policy and deny by default; add Postgres row-level security or tenant-scoped repositories as a second layer.
TOOLS: THEN → NOW
Unguessable UUIDs as protectionUUIDs as defense in depth, with object-level checks on every request
Ad hoc if-checks per routeA central policy layer and tenant-scoped data access (or database row-level security)
Manual spot checksAutomated two-account authorization tests in CI
PATTERN SNAPSHOT
// The lookup itself is scoped by the authenticated principal.
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, tenantId: req.user.tenantId },
});
// Same response for "does not exist" and "not yours".
if (!invoice || !can(req.user, "invoice:read", invoice)) return res.sendStatus(404);
res.json(toInvoiceDto(invoice));
});CLOSE THE AI. EXPLAIN THIS.
Why is switching sequential IDs to UUIDs not a fix for IDOR, and where should the ownership condition live instead?HOW IT WORKS UNDERNEATH
RELATED SYMPTOMS
SOURCES
Guide reviewed