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

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 로그인은 정상인데 남의 주문서가 열립니다
- 인증과 인가는 다른 문제입니다
- IDOR의 구조 — 그리고 UUID는 해결책이 아닙니다
- 인가 검사를 어디에 둘 것인가
- 다중 테넌트 — 테넌트 조건을 데이터베이스가 강제하게 만들기
- 프런트엔드에서 버튼을 숨기는 것은 인가가 아닙니다
- 두 계정 교차 테스트와 감사 로그
- 마치며 — 조회 조건에 소유자를 넣으십시오
들어가며 — 로그인은 정상인데 남의 주문서가 열립니다
고객 문의가 들어옵니다. 주문 상세 페이지를 새로고침했더니 다른 회사 이름이 적힌 주문서가 떴다는 내용입니다. 로그를 봅니다.
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 조회 | 404 | 200과 본문 |
| 수평 상승, 목록 | 목록 API가 테넌트 조건 없이 조회 | B 토큰으로 전체 목록 요청 | B의 항목만 | 전체 항목 |
| 수직 상승, 기능 | 관리자 전용 엔드포인트에 일반 사용자 접근 | 일반 토큰으로 사용자 삭제 API 호출 | 403 | 200 |
| 수직 상승, 필드 | 대량 할당으로 역할 필드 변경 | 본문에 role 필드를 넣어 자기 프로필 수정 | 400 또는 무시 | 역할이 변경됨 |
| 상태 전이 | 소유자만 가능한 상태 변경 | 참여자 토큰으로 결제 승인 호출 | 403 | 상태가 바뀜 |
| 간접 참조 | 하위 리소스만 검사하고 상위는 검사 안 함 | 내 주문 ID + 남의 첨부파일 ID 조합 | 404 | 첨부파일 반환 |
| 존재 노출 | 권한 없음과 없음을 다른 코드로 구분 | 존재하는 ID와 없는 ID에 각각 요청 | 둘 다 404 | 403과 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로 바꾸는 것입니다. 전자는 표시 로직이고 후자는 발견 난이도 조정입니다. 둘 다 유용하지만, 둘 중 어느 것도 "이 사람이 이 리소스에 접근해도 되는가"라는 질문에 답하지 않습니다. 그 질문에 답하는 코드는 조회 조건 안에 있어야 합니다.