Skip to content
Published on

Authentication, Authorization, and IDOR — Why the Login Is Fine but You Can See Data That Belongs to Someone Else

Share
Authors

Introduction — The Login Is Fine, and Somebody Else Order Opens Up

A customer inquiry comes in. They refreshed the order detail page and an order with a different company name appeared. You look at the logs.

04:11:52  GET /v1/orders/10421  200  user=usr_8f21  tenant=tnt_a91  12ms
04:11:58  GET /v1/orders/10422  200  user=usr_8f21  tenant=tnt_a91  9ms
04:12:03  GET /v1/orders/10423  200  user=usr_8f21  tenant=tnt_a91  11ms

All 200s. The authentication middleware worked correctly, the token is valid, the session has not expired. Not a single request failed, so no alarm fired.

The problem is that orders 10422 and 10423 do not belong to tnt_a91. The code looked like this.

app.get('/v1/orders/:id', requireAuth, async (req, res) => {
  const order = await db.order.findUnique({ where: { id: Number(req.params.id) } })
  if (!order) return res.status(404).end()
  res.json(order)
})

requireAuth confirms "who is this request from." And that is the end of it. Nowhere is there any code asking "may this person see this order."

This is IDOR, and it is the type that occurs most often in practice and gets discovered last. The request succeeds, the response code is 200, and no exception is raised. This post covers why this defect keeps recurring and how to block it structurally.

Authentication and Authorization Are Different Problems

The two words travel together often, but they answer different questions. Authentication is "who are you," and authorization is "are you allowed to perform this action on this target."

Technically their properties differ too. Authentication happens once per request, in one place at the application boundary. Verify the token, establish the subject, done. That is why one line of middleware solves it, and why leaving it out is immediately visible. An endpoint missing authentication reveals itself the moment you call it while logged out.

Authorization is the opposite. It has to be decided several times per request, and separately for each target object. A single request that displays one order involves order ownership, attachment ownership, and access rights to the linked customer information, each on its own. The information needed for the decision lives in the database, so it cannot be finished in one middleware. So it scatters, and once scattered, it gets missed.

The difficulty of discovering the two defects differs as well. If authentication is missing, an anonymous request succeeds, so any scanner catches it. If authorization is missing, it looks like a normal request from a normal user, so automated scanners cannot catch it. It only shows up when you prepare two accounts and two resources and call across them.

Organizing privilege escalation paths by type makes it clear what has to be tested.

TypeSituationTest requestSafe responseResponse when vulnerable
Horizontal, objectReading a resource of another tenant at the same tierRead the order ID of A with the token of B404200 plus a body
Horizontal, listA list API queries without a tenant conditionRequest the whole list with the token of BOnly items of BAll items
Vertical, functionA regular user reaching an admin-only endpointCall the user delete API with a regular token403200
Vertical, fieldChanging the role field via mass assignmentEdit your own profile with a role field in the body400 or ignoredThe role changes
State transitionA state change only the owner may performCall payment approval with a participant token403The state changes
Indirect referenceOnly the child resource is checked, not the parentMy order ID combined with another attachment ID404The attachment is returned
Existence disclosureNot permitted and not found return different codesRequest an existing ID and a nonexistent one404 for both403 and 404 distinguished

The last row is often overlooked. If you return 403 when permission is missing and 404 when the resource is missing, the response code alone tells you which IDs actually exist. The principle is to hide the very existence of a resource you have no read access to, so unifying on 404 is better.

The Structure of IDOR — and UUIDs Are Not the Fix

The shape of IDOR is simple. The identifier carried in the request is used directly as the lookup condition, and nobody checks whether the result belongs to the requester.

// Vulnerable: looked up by ID alone
const order = await db.order.findUnique({ where: { id: orderId } })
// Safe: the ownership relation is part of the lookup condition
const order = await db.order.findFirst({
  where: { id: orderId, tenantId: req.user.tenantId },
})
if (!order) return res.status(404).end()

The difference is whether you check afterwards or include it in the lookup. Comparing after the lookup does work, but it leaves a lot of room for mistakes.

// Works, but easily becomes vulnerable
const order = await db.order.findUnique({ where: { id: orderId } })
if (order.tenantId !== req.user.tenantId) return res.status(404).end()

This code becomes vulnerable the instant someone deletes the one check line, writes the condition backwards, or copies it into a new endpoint and drops it. With the condition included in the lookup, omitting it leaves the result empty and the feature stops working, so it surfaces during development. A design that fails toward the safe side is better.

Here the most widespread piece of bad advice has to be corrected: the claim that "changing sequential IDs to UUIDs solves IDOR."

It is true that sequential IDs make the problem worse. If you see 10421 you can try 10422, and one loop sweeps the whole range.

for i in $(seq 10400 10500); do
  code=$(curl -s -o /dev/null -w '%{http_code}' \
    -H "Authorization: Bearer $TOKEN" "https://api.example.com/v1/orders/$i")
  [ "$code" = "200" ] && echo "leaked: $i"
done

Switch to UUIDs and this loop does not work. That is why it looks effective. But what UUIDs change is the cost of discovery, not the presence or absence of a permission check. And identifiers leak far more easily than you would think.

If a list API includes items from another tenant, the IDs are exposed right there. They keep flowing out of shared links, email notifications, webhook payloads, export files, support ticket attachments, browser history, referrer headers, server access logs, and example values left in the frontend bundle. In a collaboration service, a user who was invited and then removed already knows the IDs.

The properties of UUIDs themselves matter too. A version 1 UUID, which embeds time and node information, is effectively predictable, and the time-sortable identifiers popular lately have a timestamp in the leading digits, which shrinks the search space substantially. If you need randomness, use version 4.

To sum up: a UUID is a mitigation that makes enumeration harder, while an authorization check is a control that nothing can replace. Doing both is correct, but the order must not be reversed. With an authorization check, sequential IDs are safe; without one, UUIDs are only a matter of time.

Where to Put the Authorization Check

Putting the check in every controller is guaranteed to fail. The reason is not human carelessness but arithmetic. With 200 endpoints each handling two objects on average, there are 400 check points. The count grows with every new feature, and the reviewer has to remember this every single time. One miss out of 400 is an incident.

There are two directions for a solution. Push the check down into the data access layer, or lift it up into a policy engine.

Pushing it down into the data access layer means "making an unscoped query impossible."

from dataclasses import dataclass
from sqlalchemy.orm import Session

@dataclass(frozen=True)
class Principal:
    user_id: str
    tenant_id: str
    roles: frozenset[str]

class ScopedRepository:
    """Data cannot be reached without going through this class."""

    def __init__(self, session: Session, principal: Principal):
        self._session = session
        self._principal = principal

    def orders(self):
        return self._session.query(Order).filter(Order.tenant_id == self._principal.tenant_id)

    def order(self, order_id: str) -> Order | None:
        return self.orders().filter(Order.id == order_id).one_or_none()

    def invoices(self):
        return self._session.query(Invoice).filter(Invoice.tenant_id == self._principal.tenant_id)

The controller shrinks to this.

@router.get("/v1/orders/{order_id}")
def get_order(order_id: str, repo: ScopedRepository = Depends(scoped_repo)):
    order = repo.order(order_id)
    if order is None:
        raise HTTPException(status_code=404)
    return OrderOut.model_validate(order)

What matters here is the mechanism that enforces the pattern. If someone writes session.query(Order) directly the scope disappears, so you need a rule that forbids it.

# .semgrep/no-unscoped-query.yaml
rules:
  - id: unscoped-orm-query
    languages: [python]
    severity: ERROR
    message: Do not query the session directly. Use ScopedRepository.
    patterns:
      - pattern-either:
          - pattern: $SESSION.query(...)
          - pattern: $MODEL.objects.filter(...)
      - pattern-not-inside: |
          class ScopedRepository:
              ...
    paths:
      exclude:
        - tests/
        - migrations/
semgrep --config .semgrep/no-unscoped-query.yaml src/
src/api/reports.py
   57┆ rows = session.query(Order).filter(Order.status == "paid").all()
      ⚠ unscoped-orm-query
        Do not query the session directly. Use ScopedRepository.

1 finding.

Lifting it into a policy engine means moving the decision logic outside the code. It pays off when the rules are complex or when several services have to share the same rules.

package authz

import rego.v1

default allow := false

# Resources of the same tenant may be read
allow if {
    input.action == "orders:read"
    input.resource.tenant_id == input.subject.tenant_id
}

# A refund requires the same tenant plus an admin role
allow if {
    input.action == "orders:refund"
    input.resource.tenant_id == input.subject.tenant_id
    "billing_admin" in input.subject.roles
}

Separating the policy separates the tests too.

package authz_test

import rego.v1
import data.authz

test_cross_tenant_read_denied if {
    not authz.allow with input as {
        "action": "orders:read",
        "subject": {"tenant_id": "tnt_b04", "roles": []},
        "resource": {"tenant_id": "tnt_a91"},
    }
}

Whichever of the two you pick, the principle is the same. You have to be able to count the places in the codebase where an authorization decision happens. If that is 400 places it is unmanageable; if it is two or three, review and testing are possible.

Multi-Tenant — Making the Database Enforce the Tenant Condition

However well the application layer controls are done, batch jobs, admin tools, data migration scripts and report queries go around them. Put the last line of defense in the database and those paths are covered too.

PostgreSQL row level security is the tool for that.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

There are three settings here that are easy to miss.

Without FORCE ROW LEVEL SECURITY, the table owner bypasses the policy. If the migration account and the application account are the same, the policy is effectively switched off. Superusers and roles with the BYPASSRLS attribute always bypass it too. You have to confirm the application account does not have that attribute.

Without WITH CHECK, only reads are blocked while inserting a row with another tenant ID is still allowed.

Setting the second argument of current_setting to true makes it return NULL instead of an error when the setting is absent. A comparison with NULL is false, so no rows are visible. When you forget the setting, it fails in the direction of showing nothing rather than showing everything.

Set the context at transaction scope. In an environment with a connection pool, setting it at session scope leaks the value into the next request.

BEGIN;
SET LOCAL app.tenant_id = '3f2b8c14-9a71-4f0e-8d33-6c2a15b7e901';
SELECT id, total_amount FROM orders WHERE id = 10422;
COMMIT;
 id | total_amount
----+--------------
(0 rows)

Apply it automatically from the application code.

from sqlalchemy import event, text
from sqlalchemy.orm import Session

@event.listens_for(Session, "after_begin")
def apply_tenant_context(session, transaction, connection):
    tenant_id = current_tenant.get(None)
    if tenant_id is None:
        raise RuntimeError("A transaction cannot begin without a tenant context")
    connection.execute(
        text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
        {"tenant_id": str(tenant_id)},
    )

Be sure to keep a test that confirms the policy actually works. RLS switches off quietly.

-- Find tables with no policy applied
SELECT c.relname AS table_name, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'app' AND c.relkind = 'r'
ORDER BY c.relrowsecurity, c.relname;
    table_name    | relrowsecurity | relforcerowsecurity
------------------+----------------+---------------------
 audit_events     | f              | f
 webhook_deliver  | f              | f
 orders           | t              | t
 invoices         | t              | t

To keep a newly added table from shipping without a policy, it is a good idea to turn this query into a CI check.

Hiding a Button in the Frontend Is Not Authorization

If a review answer is "regular users do not see this button," that is not an explanation of authorization. It is UI display logic.

// Screen control only, not a guarantee that the server rejects the request
{user.role === 'admin' && <DeleteButton onClick={remove} />}

How the server actually behaves is something you only learn by sending the request yourself.

curl -sS -X POST https://api.example.com/v1/users/812/role \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"role":"admin"}' -w '\n%{http_code}\n'
{"id":812,"email":"viewer@example.com","role":"admin"}
200

The button was hidden but the API was wide open. A viewer-level account made itself an administrator.

A variant of the same problem is mass assignment. If a profile update API reflects the request body straight into the model, fields absent from the UI change as well.

// Vulnerable: the entire body is applied
await db.user.update({ where: { id: req.user.id }, data: req.body })
curl -sS -X PATCH https://api.example.com/v1/me \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"displayName":"Kim","role":"admin","tenantId":"tnt_a91"}'

The defense is to state the permitted fields explicitly. It has to be an allowlist, not a denylist. When a new field is added, a denylist is breached automatically.

import { z } from 'zod'

const UpdateMe = z
  .object({
    displayName: z.string().min(1).max(80),
    locale: z.enum(['ko', 'en', 'ja']).optional(),
  })
  .strict()

app.patch('/v1/me', requireAuth, async (req, res) => {
  const parsed = UpdateMe.safeParse(req.body)
  if (!parsed.success) return res.status(400).json({ error: 'invalid payload' })
  const user = await db.user.update({ where: { id: req.user.id }, data: parsed.data })
  res.json({ id: user.id, displayName: user.displayName })
})

GraphQL and gRPC are no exception. If anything, field-level access grows and the number of check points goes up. Rather than putting authorization in every resolver, it is safer to enforce the scope in the data loader layer.

Two-Account Cross Tests and the Audit Log

Because an authorization defect looks like a normal request, catching it automatically requires a dedicated test. The principle is simple. Create a resource with account A, then attempt every action against that same resource with the token of account B.

import pytest

# A fixture fills in the identifiers of the resources created by account A
CROSS_TENANT_CASES = [
    ("GET", "/v1/orders/{order_id}"),
    ("PATCH", "/v1/orders/{order_id}"),
    ("DELETE", "/v1/orders/{order_id}"),
    ("GET", "/v1/orders/{order_id}/attachments/{attachment_id}"),
    ("GET", "/v1/invoices/{invoice_id}"),
    ("POST", "/v1/invoices/{invoice_id}/refund"),
    ("GET", "/v1/webhooks/{webhook_id}"),
]

@pytest.mark.parametrize("method,template", CROSS_TENANT_CASES)
def test_tenant_b_cannot_touch_tenant_a(client, tenant_a_resources, tenant_b_auth, method, template):
    url = template.format(**tenant_a_resources)
    response = client.request(method, url, headers=tenant_b_auth, json={})
    assert response.status_code == 404, (
        f"{method} {url} returned {response.status_code}. "
        f"body excerpt: {response.text[:120]}"
    )
FAILED tests/test_authz.py::test_tenant_b_cannot_touch_tenant_a[GET-/v1/orders/{order_id}/attachments/{attachment_id}]
AssertionError: GET /v1/orders/ord_5512/attachments/att_9931 returned 200.
body excerpt: {"id":"att_9931","filename":"2026-06-invoice.pdf","url":"https://cdn...

The order itself was blocked but the attachment got through. This is the textbook shape of checking only the parent resource and not the child. It is hard for a human to catch in review and this test catches it instantly.

Go one step further and you can diff the registered route list against the test list. New endpoints without a cross test fail the build.

def test_every_resource_route_has_cross_tenant_case(app):
    covered = {template for _, template in CROSS_TENANT_CASES}
    exempt = {"/health", "/v1/auth/login", "/v1/auth/refresh", "/v1/signup"}

    missing = [
        route.path
        for route in app.routes
        if "{" in route.path and route.path not in covered and route.path not in exempt
    ]
    assert not missing, f"routes without a cross-tenant test: {missing}"

This one test blocks most of the incident type where "the new endpoint forgot its authorization check." The point is to hand the check to CI rather than to human memory.

The last piece is the audit log. The common mistake here is recording only successes.

{
  "ts": "2026-07-26T04:12:03.117Z",
  "request_id": "01J8ZQ4T7K3M9F0X2B6C5A1D8E",
  "actor_id": "usr_8f21",
  "actor_tenant": "tnt_a91",
  "action": "orders:read",
  "resource_type": "order",
  "resource_id": "ord_10423",
  "resource_tenant": "tnt_b04",
  "decision": "deny",
  "reason": "tenant_mismatch",
  "source_ip": "203.0.113.44",
  "user_agent": "python-requests/2.32.3"
}

Four fields absolutely must be in this record: the tenant of the actor, the tenant of the target resource, the decision, and the reason for the decision. Only when the first two are present together can you find cross-access attempts with a query.

SELECT actor_id, count(*) AS denies, count(DISTINCT resource_id) AS distinct_targets
FROM audit_events
WHERE decision = 'deny'
  AND reason = 'tenant_mismatch'
  AND ts > now() - interval '10 minutes'
GROUP BY actor_id
HAVING count(*) > 20
ORDER BY denies DESC;
 actor_id | denies | distinct_targets
----------+--------+------------------
 usr_8f21 |    317 |              317

317 distinct resources denied within 10 minutes. That means an enumeration attempt is in progress, and this signal does not exist unless you record denials. Conversely, if authorization is broken everything succeeds, so the deny log goes to zero. So this metric is useful in both directions. A sudden rise means an attack attempt; a sudden disappearance after a deploy means the check disappeared.

Wrapping Up — Put the Owner Into the Lookup Condition

The conclusion of this post shrinks to one line. Do not verify permission after fetching the resource; put the permission into the lookup condition.

This single habit blocks very nearly all of IDOR. Put it in the condition and, if you omit it, the result comes back empty and the feature stops working, so it surfaces during development. Check afterwards and, if you omit it, nothing happens at all, so it surfaces in production as an incident.

Everything else is machinery for sustaining that habit. A data access layer that makes unscoped queries impossible, row level security that blocks it even when the application slips, cross-tenant tests that stop new endpoints from being added without a check, and deny logs that make enumeration attempts visible.

And two things must be remembered as not being authorization: hiding a button in the frontend, and changing identifiers to UUIDs. The former is display logic and the latter adjusts the cost of discovery. Both are useful, but neither one answers the question "may this person access this resource." The code that answers that question has to live inside the lookup condition.