이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 인증 성공과 인가 성공은 왜 분리해서 설계해야 하는가?
- 로그인한 사용자가 다른 사용자의 리소스 ID를 넣을 때 서버는 어디서 막아야 하는가?
- 401, 403, 404 응답과 권한 거부 로그를 어떻게 구분해야 하는가?
개요
Authentication은 “이 요청자가 누구인지 확인했다”는 뜻이다.
Authorization은 “이 요청자가 이 resource에 이 action을 해도 된다”는 뜻이다.
로그인 성공은 인가의 시작점일 뿐이다. 로그인한 사용자가 다른 사용자의 주문, 문서, 결제, 팀 설정을 볼 수 있는 것은 아니다.
권한 취약점의 많은 부분은 이 두 개념을 섞을 때 생긴다. 코드에서는 isAuthenticated()만 확인하고 실제 resource ownership을 확인하지 않는 형태로 드러난다.
공격 시나리오
사용자 1004가 자기 주문을 조회한다.
GET /api/orders/801 HTTP/1.1
Authorization: Bearer member-1004-token응답을 보고 공격자는 URL의 주문 ID를 바꾼다.
GET /api/orders/991 HTTP/1.1
Authorization: Bearer member-1004-token서버가 “로그인했는가”만 보고 orders.id = 991로 조회하면 사용자 2002의 주문이 노출된다. 이것이 IDOR의 전형적인 모양이다.
공격 요청은 이상하지 않다. 정상 endpoint, 정상 token, 정상 HTTP method를 쓴다. 바뀐 것은 resource id뿐이다.
취약한 요청/응답 예시
GET /api/users/2002/orders/991 HTTP/1.1
Host: api.example.com
Authorization: Bearer member-1004-tokenHTTP/1.1 200 OK
Content-Type: application/json
{
"orderId": 991,
"memberId": 2002,
"totalPrice": 42000,
"shippingAddress": "Seoul ..."
}문제는 bearer token이 틀린 것이 아니다. 인증은 성공했지만 주문의 소유자와 principal의 관계를 확인하지 않았다.
주제별 핵심 판단
- 인증은 “누구인가”이고 인가는 “이 resource에 이 action을 해도 되는가”다.
- 인증 성공을 권한 허용으로 사용하면 IDOR와 수평 권한 상승이 생긴다.
개선된 요청/응답 예시
인가 판단은 다음 네 가지를 함께 본다.
- 개선된 API는 principal을 서버에서 얻고 resource owner와 action 정책을 별도로 확인한다.
- 거부 응답은 403으로 통일하되 로그에는 owner mismatch나 missing permission을 남긴다.
방어 설계
actor: authenticated member 1004
resource: order 991
action: read
context: normal customer API이 네 가지가 모여야 decision이 된다.
allow if order.member_id == principal.member_id
deny if order.member_id != principal.member_idAPI path도 혼동을 줄인다.
나쁜 형태: GET /api/users/{userId}/orders/{orderId}
나은 형태: GET /api/me/orders/{orderId}하지만 /me를 쓰는 것만으로 충분하지 않다. 서버는 여전히 orderId와 principal 관계를 확인해야 한다.
응답 코드는 정책에 맞게 정한다.
- 401: 인증이 없거나 유효하지 않다.
- 403: 인증은 되었지만 action이 거부된다.
- 404: resource 존재 여부 노출을 줄이기 위해 “없는 것처럼” 응답한다.
어떤 응답을 쓰든 서버 감사 로그에는 실제 reason을 남겨야 한다.
Spring/HTTP 예시
@GetMapping("/api/me/orders/{orderId}")
public OrderResponse getMyOrder(
@AuthenticationPrincipal AccountPrincipal principal,
@PathVariable long orderId) {
return orderQueryService.getOrderForMember(principal.accountId(), orderId);
}@Transactional(readOnly = true)
public OrderResponse getOrderForMember(long memberId, long orderId) {
Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
.orElseThrow(() -> {
audit.authorizationDenied(memberId, "order", orderId, "read", "ownership_mismatch");
return new NotFoundException("order not found");
});
return OrderResponse.from(order);
}Repository 조건에 principal context가 들어가면 실수 여지가 줄어든다.
SELECT id, member_id, total_price, status
FROM orders
WHERE id = ?
AND member_id = ?;운영 로그와 감사 지점
level=WARN event=authorization_denied result=blocked actor_id=1004 resource_type=order resource_id=991 action=read reason=ownership_mismatch response_status=404 trace_id=6f12aa90로그에는 외부 응답보다 더 정확한 reason을 남긴다. 응답은 404일 수 있어도 내부 reason은 ownership_mismatch여야 사고 분석이 가능하다.
실전 판단 기준
- 인증 없는 요청은 401인가?
- 모든 인증된 사용자 허용 규칙이 민감 resource에 닿지 않는지 확인한다.
- 테스트에는 반드시 정상 사용자와 비소유자 사용자가 함께 있어야 한다.
테스트 포인트
- 다른 사용자의 주문 ID를 넣으면 403 또는 404인가?
- 거부 로그에 actor, resource, action, reason이 남는가?
- Controller가 아니라 Service/Repository에서도 principal context가 쓰이는가?
userId를 body나 query로 보내도 서버 권한 결정에 사용하지 않는가?- list API와 detail API가 같은 ownership 조건을 적용하는가?
위험 신호!
isAuthenticated()만 확인하고 resource owner를 보지 않는다.- URL의
userId와 token의 subject가 달라도 요청이 성공한다. - 프론트에서 남의 링크를 숨겼으니 충분하다고 말한다.
- 403/404 응답은 있지만 감사 로그에 decision reason이 없다.
- list 조회에는 권한 조건이 있는데 detail 조회에는 없다.
- 관리자 예외 경로가 일반 사용자 경로와 같은 정책 없이 Service를 호출한다.
확인 질문
확인 질문
- 인증과 인가를 분리해야 하는 이유는 무엇인가?
- 로그인은 신원 확인일 뿐이고, 각 resource와 action마다 별도의 허용 판단이 필요하기 때문이다.
- IDOR를 막는 가장 직접적인 DB 조건은 무엇인가?
- resource id와 principal의 owner 또는 tenant id를 함께 조건으로 조회하는 것이다.
- 404로 응답해도 감사 로그에 reason을 남겨야 하는 이유는 무엇인가?
- 외부에는 존재 여부를 숨기더라도 내부에서는 권한 거부와 실제 원인을 추적해야 하기 때문이다.