← Back to the library
SecurityCore · 7 min

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

  1. 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.
  2. Scope lookups at the query: where id = ? and tenant_id = ?, or currentUser.projects.find(id), instead of fetching by id and checking later.
  3. Take the user and tenant from the session, never from request parameters or bodies.
  4. Return the same 404 for missing and forbidden objects so IDs cannot be probed for existence.
  5. 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

SOURCES

Guide reviewed