Skip to content

Split View: 인증과 인가, 그리고 IDOR — 로그인은 정상인데 남의 데이터가 보이는 이유

✨ Learn with Quiz
|

인증과 인가, 그리고 IDOR — 로그인은 정상인데 남의 데이터가 보이는 이유

들어가며 — 로그인은 정상인데 남의 주문서가 열립니다

고객 문의가 들어옵니다. 주문 상세 페이지를 새로고침했더니 다른 회사 이름이 적힌 주문서가 떴다는 내용입니다. 로그를 봅니다.

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

전부 200입니다. 인증 미들웨어는 정상 동작했고, 토큰도 유효하며, 세션도 만료되지 않았습니다. 실패한 요청이 하나도 없으니 알람도 울리지 않았습니다.

문제는 10422번과 10423번 주문이 tnt_a91 소유가 아니라는 것입니다. 코드는 이렇게 되어 있었습니다.

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는 "이 요청이 누구인가"를 확인합니다. 그리고 그것으로 끝입니다. "이 사람이 이 주문을 볼 수 있는가"를 묻는 코드는 어디에도 없습니다.

이것이 IDOR이고, 실무에서 가장 자주 발생하면서 가장 늦게 발견되는 유형입니다. 요청은 성공하고, 응답 코드는 200이며, 어떤 예외도 나지 않기 때문입니다. 이 글은 이 결함이 왜 반복해서 생기는지, 그리고 구조적으로 어떻게 막는지 다룹니다.

인증과 인가는 다른 문제입니다

두 단어는 자주 붙어 다니지만 다루는 질문이 다릅니다. 인증은 "당신은 누구입니까"이고, 인가는 "당신이 이 대상에 이 동작을 해도 됩니까"입니다.

기술적으로도 성질이 다릅니다. 인증은 요청당 한 번, 애플리케이션 경계에서 한 곳에서 처리됩니다. 토큰을 검증하고 주체를 확정하면 끝입니다. 그래서 미들웨어 한 줄로 해결되고, 빠뜨리면 즉시 눈에 띕니다. 인증이 빠진 엔드포인트는 로그인하지 않은 상태로 호출해 보면 바로 드러납니다.

인가는 반대입니다. 요청마다 여러 번, 그리고 대상 객체마다 따로 판단해야 합니다. 주문 하나를 보여 주는 요청에도 주문 소유권, 첨부파일 소유권, 연결된 고객 정보 접근 권한이 각각 걸립니다. 판단에 필요한 정보가 데이터베이스에 있으므로 미들웨어 한 곳에서 끝낼 수 없습니다. 그래서 흩어지고, 흩어지면 빠집니다.

인증 결함과 인가 결함의 발견 난이도도 다릅니다. 인증이 없으면 익명 요청이 성공하므로 어떤 스캐너든 잡습니다. 인가가 없으면 정상 사용자의 정상 요청처럼 보이므로 자동 스캐너가 잡지 못합니다. 두 개의 계정과 두 개의 리소스를 준비해 교차로 호출해 봐야만 드러납니다.

권한 상승 경로를 유형별로 정리하면 무엇을 테스트해야 하는지 명확해집니다.

유형상황시험 요청안전한 응답취약할 때 응답
수평 상승, 객체같은 등급의 다른 테넌트 리소스 조회B 토큰으로 A의 주문 ID 조회404200과 본문
수평 상승, 목록목록 API가 테넌트 조건 없이 조회B 토큰으로 전체 목록 요청B의 항목만전체 항목
수직 상승, 기능관리자 전용 엔드포인트에 일반 사용자 접근일반 토큰으로 사용자 삭제 API 호출403200
수직 상승, 필드대량 할당으로 역할 필드 변경본문에 role 필드를 넣어 자기 프로필 수정400 또는 무시역할이 변경됨
상태 전이소유자만 가능한 상태 변경참여자 토큰으로 결제 승인 호출403상태가 바뀜
간접 참조하위 리소스만 검사하고 상위는 검사 안 함내 주문 ID + 남의 첨부파일 ID 조합404첨부파일 반환
존재 노출권한 없음과 없음을 다른 코드로 구분존재하는 ID와 없는 ID에 각각 요청둘 다 404403과 404 구분

마지막 줄이 자주 간과됩니다. 권한이 없을 때 403, 리소스가 없을 때 404를 돌려주면 응답 코드만으로 어떤 ID가 실재하는지 알 수 있습니다. 조회 권한이 없는 리소스는 그 존재 자체를 숨기는 것이 원칙이므로 404로 통일하는 편이 낫습니다.

IDOR의 구조 — 그리고 UUID는 해결책이 아닙니다

IDOR의 형태는 단순합니다. 요청에 담긴 식별자를 그대로 조회 조건으로 쓰고, 그 결과가 요청자의 것인지 확인하지 않는 것입니다.

// 취약: ID만으로 조회한다
const order = await db.order.findUnique({ where: { id: orderId } })
// 안전: 소유 관계를 조회 조건에 포함한다
const order = await db.order.findFirst({
  where: { id: orderId, tenantId: req.user.tenantId },
})
if (!order) return res.status(404).end()

차이는 검사를 나중에 하느냐 조회에 포함하느냐입니다. 조회 뒤에 비교하는 방식도 동작은 하지만 실수 여지가 큽니다.

// 동작은 하지만 취약해지기 쉬운 형태
const order = await db.order.findUnique({ where: { id: orderId } })
if (order.tenantId !== req.user.tenantId) return res.status(404).end()

이 코드는 검사 한 줄을 지우거나, 조건을 반대로 쓰거나, 새 엔드포인트에서 복사하며 빠뜨리면 즉시 취약해집니다. 조건에 포함하는 방식은 빠뜨리면 결과가 비어 기능이 동작하지 않으므로 개발 중에 드러납니다. 안전한 쪽으로 실패하는 설계가 낫습니다.

여기서 가장 널리 퍼진 잘못된 조언을 정정해야 합니다. "순차 ID를 UUID로 바꾸면 IDOR가 해결된다"는 것입니다.

순차 ID가 문제를 키우는 것은 사실입니다. 10421을 보면 10422를 시도해 볼 수 있고, 반복문 한 줄로 전체를 훑을 수 있습니다.

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

UUID로 바꾸면 이 반복문은 동작하지 않습니다. 그래서 효과가 있어 보입니다. 하지만 UUID가 바꾸는 것은 발견 난이도이지 권한 검사의 유무가 아닙니다. 그리고 식별자는 생각보다 훨씬 쉽게 새어 나갑니다.

목록 API가 다른 테넌트의 항목을 포함하고 있으면 ID가 그대로 노출됩니다. 공유 링크, 이메일 알림, 웹훅 페이로드, 내보내기 파일, 지원 티켓 첨부, 브라우저 히스토리, 리퍼러 헤더, 서버 접근 로그, 프런트엔드 번들에 남은 예시 값에서 계속 흘러나옵니다. 협업 서비스라면 초대받았다가 제외된 사용자가 이미 ID를 알고 있습니다.

UUID 자체의 성질도 봐야 합니다. 시간과 노드 정보를 담는 버전 1 UUID는 실질적으로 예측 가능하고, 최근 많이 쓰이는 시간 정렬형 식별자도 앞자리가 타임스탬프라 탐색 공간이 크게 줄어듭니다. 무작위성이 필요하다면 버전 4를 써야 합니다.

정리하면 이렇습니다. UUID는 열거를 어렵게 만드는 완화책이고 인가 검사는 대체 불가능한 통제입니다. 둘 다 하는 것이 맞지만, 순서가 바뀌면 안 됩니다. 인가 검사가 있으면 순차 ID여도 안전하고, 인가 검사가 없으면 UUID여도 시간 문제입니다.

인가 검사를 어디에 둘 것인가

컨트롤러마다 검사를 넣는 방식은 반드시 실패합니다. 이유는 사람의 부주의가 아니라 산술입니다. 엔드포인트가 200개이고 각각 평균 두 개의 객체를 다루면 검사 지점이 400개입니다. 새 기능이 추가될 때마다 늘어나고, 리뷰어는 매번 이것을 기억해야 합니다. 400개 중 하나만 빠져도 사고입니다.

해결 방향은 두 가지입니다. 검사를 데이터 접근 계층으로 내리거나, 정책 엔진으로 올리는 것입니다.

데이터 접근 계층으로 내리는 방식은 "범위 없는 조회를 불가능하게 만드는 것"입니다.

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:
    """이 클래스를 거치지 않으면 데이터에 접근할 수 없다."""

    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)

컨트롤러는 이렇게 짧아집니다.

@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)

여기서 중요한 것은 이 패턴을 강제하는 장치입니다. 누군가 session.query(Order)를 직접 쓰면 범위가 사라지므로, 그것을 금지하는 규칙이 필요합니다.

# .semgrep/no-unscoped-query.yaml
rules:
  - id: unscoped-orm-query
    languages: [python]
    severity: ERROR
    message: 세션에서 직접 조회하지 말고 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
        세션에서 직접 조회하지 말고 ScopedRepository를 사용하십시오.

1 finding.

정책 엔진으로 올리는 방식은 판단 로직을 코드 밖으로 빼는 것입니다. 규칙이 복잡하거나 여러 서비스가 같은 규칙을 공유해야 할 때 유리합니다.

package authz

import rego.v1

default allow := false

# 같은 테넌트의 리소스는 읽을 수 있다
allow if {
    input.action == "orders:read"
    input.resource.tenant_id == input.subject.tenant_id
}

# 환불은 같은 테넌트이면서 관리자 역할이 있어야 한다
allow if {
    input.action == "orders:refund"
    input.resource.tenant_id == input.subject.tenant_id
    "billing_admin" in input.subject.roles
}

정책을 분리하면 테스트도 분리됩니다.

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"},
    }
}

둘 중 무엇을 고르든 원칙은 같습니다. 인가 판단이 일어나는 위치를 코드베이스에서 셀 수 있어야 합니다. 그 위치가 400곳이면 관리 불가능하고, 두세 곳이면 리뷰와 테스트가 가능합니다.

다중 테넌트 — 테넌트 조건을 데이터베이스가 강제하게 만들기

애플리케이션 계층의 통제가 아무리 잘 되어 있어도 배치 잡, 관리자 도구, 데이터 마이그레이션 스크립트, 리포트 쿼리가 그것을 우회합니다. 마지막 방어선을 데이터베이스에 두면 이런 경로까지 덮입니다.

PostgreSQL의 행 수준 보안이 그 도구입니다.

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);

여기서 놓치기 쉬운 설정이 세 개 있습니다.

FORCE ROW LEVEL SECURITY가 없으면 테이블 소유자는 정책을 우회합니다. 마이그레이션 계정과 애플리케이션 계정이 같으면 정책이 사실상 꺼진 상태가 됩니다. 슈퍼유저와 BYPASSRLS 속성이 있는 롤도 항상 우회합니다. 애플리케이션 계정에 이 속성이 없는지 확인해야 합니다.

WITH CHECK가 없으면 읽기만 막히고 다른 테넌트 ID로 행을 삽입하는 것은 허용됩니다.

current_setting의 두 번째 인자를 true로 두면 설정이 없을 때 오류 대신 NULL이 반환됩니다. NULL과의 비교는 거짓이므로 아무 행도 보이지 않습니다. 설정을 빠뜨렸을 때 전체가 보이는 것이 아니라 아무것도 안 보이는 방향으로 실패합니다.

컨텍스트는 트랜잭션 범위로 설정합니다. 커넥션 풀을 쓰는 환경에서 세션 범위로 설정하면 값이 다음 요청으로 새어 갑니다.

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)

애플리케이션 코드에서 자동으로 걸어 줍니다.

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("테넌트 컨텍스트 없이 트랜잭션을 시작할 수 없습니다")
    connection.execute(
        text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
        {"tenant_id": str(tenant_id)},
    )

정책이 실제로 동작하는지 확인하는 테스트를 반드시 두십시오. RLS는 조용히 꺼지기 쉽습니다.

-- 정책이 걸리지 않은 테이블 찾기
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

새 테이블이 추가될 때 정책 없이 배포되는 것을 막으려면 이 쿼리를 CI 검사로 만드는 것이 좋습니다.

프런트엔드에서 버튼을 숨기는 것은 인가가 아닙니다

리뷰에서 "일반 사용자에게는 이 버튼이 안 보입니다"라는 답변이 나오면 그것은 인가 설명이 아닙니다. UI 표시 로직입니다.

// 화면 제어일 뿐, 서버가 요청을 거부한다는 보장이 아니다
{user.role === 'admin' && <DeleteButton onClick={remove} />}

서버가 실제로 어떻게 동작하는지는 요청을 직접 보내 봐야 압니다.

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

버튼을 숨겼는데 API는 열려 있었습니다. 뷰어 권한 계정이 스스로 관리자가 됐습니다.

같은 문제의 변형이 대량 할당입니다. 프로필 수정 API가 요청 본문을 모델에 그대로 반영하면, UI에 없는 필드도 바뀝니다.

// 취약: 본문 전체가 반영된다
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"}'

방어는 허용 필드를 명시하는 것입니다. 금지 목록이 아니라 허용 목록이어야 합니다. 새 필드가 추가될 때 금지 목록은 자동으로 뚫립니다.

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과 gRPC도 예외가 아닙니다. 오히려 필드 단위 접근이 늘어나 검사 지점이 많아집니다. 리졸버마다 인가를 두는 대신, 데이터 로더 계층에서 범위를 강제하는 편이 안전합니다.

두 계정 교차 테스트와 감사 로그

인가 결함은 정상 요청처럼 보이므로 자동으로 잡으려면 전용 테스트가 필요합니다. 원리는 단순합니다. 계정 A로 리소스를 만들고, 계정 B의 토큰으로 같은 리소스에 모든 동작을 시도합니다.

import pytest

# 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}{response.status_code} 를 반환했습니다. "
        f"본문 일부: {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 이 200 를 반환했습니다.
본문 일부: {"id":"att_9931","filename":"2026-06-invoice.pdf","url":"https://cdn...

주문 자체는 막혔는데 첨부파일은 뚫렸습니다. 상위 리소스만 검사하고 하위 리소스는 검사하지 않는 전형적인 형태입니다. 사람이 리뷰로 잡기 어렵고 이 테스트로는 즉시 잡힙니다.

한 걸음 더 나아가면 라우트 등록 목록과 테스트 목록을 대조할 수 있습니다. 새 엔드포인트가 추가됐는데 교차 테스트가 없으면 실패시키는 방식입니다.

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"교차 테넌트 테스트가 없는 라우트: {missing}"

이 테스트 하나가 "새로 만든 엔드포인트에서 인가를 빠뜨리는" 사고 유형 대부분을 막습니다. 검사를 사람의 기억이 아니라 CI에 맡기는 것이 요점입니다.

마지막이 감사 로그입니다. 여기서 흔한 실수는 성공만 기록하는 것입니다.

{
  "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"
}

이 레코드에 반드시 있어야 할 필드는 네 개입니다. 행위자의 테넌트, 대상 리소스의 테넌트, 판정 결과, 그리고 판정 이유입니다. 앞의 두 개가 함께 있어야 교차 접근 시도를 질의로 찾을 수 있습니다.

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

10분 동안 317개의 서로 다른 리소스에 거부됐습니다. 열거 시도가 진행 중이라는 뜻이고, 이 신호는 거부를 기록하지 않으면 존재하지 않습니다. 반대로 인가가 뚫려 있으면 모두 성공하므로 거부 로그가 0이 됩니다. 그래서 이 지표는 두 방향으로 유용합니다. 갑자기 늘면 공격 시도이고, 배포 후 갑자기 사라지면 검사가 사라진 것입니다.

마치며 — 조회 조건에 소유자를 넣으십시오

이 글의 결론은 한 줄로 줄어듭니다. 리소스를 가져온 뒤에 권한을 확인하지 말고, 권한을 조회 조건에 넣으십시오.

이 한 가지 습관이 IDOR의 거의 전부를 막습니다. 조건에 넣으면 빠뜨렸을 때 결과가 비어 기능이 동작하지 않고, 그래서 개발 단계에서 드러납니다. 나중에 확인하는 방식은 빠뜨려도 아무 일이 일어나지 않고, 그래서 운영에서 사고로 드러납니다.

나머지는 이 습관을 유지하기 위한 장치입니다. 범위 없는 조회를 불가능하게 만드는 데이터 접근 계층, 애플리케이션이 실수해도 막아 주는 행 수준 보안, 새 엔드포인트가 검사 없이 추가되는 것을 막는 교차 테넌트 테스트, 그리고 열거 시도를 보이게 만드는 거부 로그입니다.

그리고 두 가지는 인가가 아니라는 점을 기억해야 합니다. 프런트엔드에서 버튼을 숨기는 것과 식별자를 UUID로 바꾸는 것입니다. 전자는 표시 로직이고 후자는 발견 난이도 조정입니다. 둘 다 유용하지만, 둘 중 어느 것도 "이 사람이 이 리소스에 접근해도 되는가"라는 질문에 답하지 않습니다. 그 질문에 답하는 코드는 조회 조건 안에 있어야 합니다.

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

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.