URL에 workspaceId=100이 들어 있는 화면이 있다고 해 보자. 숫자를 101로 바꿨더니 다른 워크스페이스의 문서 목록이 보인다. 로그인은 정상적으로 되어 있고, 호출한 API도 원래 이 사용자가 쓸 수 있는 API다. 달라진 것은 조회 대상의 ID뿐이다.
이런 상황을 BOLA(Broken Object Level Authorization)라고 부른다.
처음 이 문제를 발견하면 프런트엔드 개발자는 당황하기 쉽다. 주소창의 값을 API에 그대로 전달한 코드가 눈앞에 있기 때문이다. 라우터에서 받은 ID를 로그인 사용자의 ID와 비교하는 코드를 급히 넣으면 해결될 것처럼 보인다.
하지만 브라우저에서 실행되는 코드는 사용자가 수정할 수 있다. 화면 진입을 막거나 버튼을 숨겨도 API는 별도로 호출할 수 있다. 프런트엔드는 BOLA를 최종적으로 막는 주체가 아니다. 객체에 접근할 권한이 있는지는 그 객체를 반환하거나 변경하는 서버가 검사해야 한다.
그렇다고 프런트엔드 개발자가 할 일이 없는 것은 아니다. 오히려 실제 서비스에서 어떤 식별자가 어디에서 만들어지고 어떤 API로 흘러가는지 가장 빨리 연결할 수 있는 사람이 프런트엔드 개발자일 때가 많다.
프런트엔드의 역할은 서버의 권한 검사를 대신 구현하는 것이 아니다. 공격 표면을 좁히고, 재현 가능한 증거를 만들고, 백엔드 개발자가 올바른 경계를 빠르게 수정하도록 돕는 것이다.
이 글에서는 그 과정을 정리한다.
먼저 인증과 인가를 나눠 보자
BOLA를 이해하려면 인증과 인가를 먼저 구분해야 한다.
- 인증(Authentication): 이 요청을 보낸 사용자가 누구인지 확인한다.
- 인가(Authorization): 확인된 사용자가 이 객체에 이 작업을 수행해도 되는지 확인한다.
유효한 토큰이 있다는 사실은 인증에 성공했다는 뜻이다. 그 토큰의 사용자가 요청에 포함된 모든 workspaceId, orderId, documentId에 접근할 수 있다는 뜻은 아니다.
예를 들어 다음 요청이 정상적으로 사용되는 API라고 해 보자.
GET /api/documents?workspaceId=100
Authorization: Bearer <token-for-workspace-100>
서버는 최소한 다음 두 질문에 각각 답해야 한다.
- 이 토큰은 유효한가?
- 이 토큰의 주체가
workspaceId=100의 문서를 조회할 수 있는가?
첫 번째만 검사하고 두 번째를 검사하지 않는다면 사용자는 workspaceId=101로 바꾼 요청을 보낼 수 있다. 인증된 사용자라는 이유로 다른 객체까지 열어 주는 순간 객체 단위 인가가 깨진다.
BOLA는 정확히 무엇인가
OWASP API Security Top 10의 API1:2023은 BOLA를 클라이언트가 전달한 객체 식별자를 사용하는 모든 API에서 고려해야 하는 객체 단위 접근 제어 문제로 설명한다.
핵심은 세 가지다.
- 사용자는 해당 API 기능 자체를 사용할 수 있다.
- 요청에 객체를 특정하는 ID가 들어간다.
- 서버가 현재 사용자와 대상 객체의 관계를 충분히 검사하지 않는다.
객체 ID는 순차적인 숫자일 필요가 없다. UUID나 추측하기 어려운 문자열이어도 다른 경로에서 유출되거나 공유될 수 있다. 예측하기 어려운 ID는 공격 비용을 높이는 보조 수단일 뿐, 인가 검사를 대신하지 못한다.
읽기뿐 아니라 수정과 삭제도 같은 문제다.
GET /api/documents/{documentId}
PATCH /api/documents/{documentId}
DELETE /api/documents/{documentId}
다른 사용자의 문서가 조회되면 정보 노출이고, 수정되면 데이터 훼손이며, 삭제되면 복구하기 어려운 사고가 된다. 따라서 “GET에서 데이터가 보이는가?”만 확인하고 끝낼 수 없다. 같은 식별자를 사용하는 쓰기 API도 별도의 인가 경계를 가져야 한다.
API가 권한 밖의 데이터를 주는 문제는 모두 BOLA일까
그렇지는 않다. 무엇에 대한 권한이 깨졌는지를 구분해야 담당자가 올바른 곳을 수정할 수 있다.
| 질문 | 대표 분류 | 예시 |
|---|---|---|
| 이 사용자가 이 객체에 접근해도 되는가? | BOLA | 다른 워크스페이스의 문서 조회 |
| 이 객체의 이 속성을 읽거나 바꿔도 되는가? | BOPLA | 공개 프로필 응답에 내부 등급이나 계좌 정보 포함 |
| 이 사용자가 이 기능을 실행해도 되는가? | BFLA | 일반 사용자가 관리자용 전체 다운로드 API 호출 |
| 권한과 무관한 필드가 단지 많이 포함됐는가? | API 설계·성능 문제 | 민감하지 않은 표시용 데이터 포함 |
객체가 다르면 BOLA
사용자가 자신의 주문 조회 기능은 쓸 수 있지만 orderId를 바꿔 다른 사람의 주문을 볼 수 있다면 BOLA다. 기능 권한은 있지만 대상 객체에 대한 권한이 없다.
속성이 문제라면 BOPLA
사용자가 특정 객체를 조회할 권한은 있지만 응답에 해당 사용자가 보지 말아야 할 속성이 포함될 수도 있다. 또는 서버가 수정 가능한 속성만 허용해야 하는 API에서, 클라이언트가 role, approved, internalScore 같은 필드를 추가했더니 그대로 반영될 수도 있다.
OWASP는 이를 API3:2023 Broken Object Property Level Authorization으로 분류한다. 이전의 Excessive Data Exposure와 Mass Assignment를 속성 단위 인가라는 공통 원인으로 묶은 것이다.
응답 필드가 많다는 이유만으로 곧바로 보안 취약점인 것은 아니다. 사용자가 볼 권한이 없는 민감한 속성이 포함되어야 권한 문제다. 민감하지 않지만 사용하지 않는 필드는 스키마 관리와 성능 측면에서 줄일 대상이지만 BOLA와는 다르다.
기능 자체가 문제라면 BFLA
일반 사용자에게는 허용되지 않은 관리자 API를 URL 추측이나 HTTP 메서드 변경만으로 호출할 수 있다면 API5:2023 Broken Function Level Authorization에 가깝다.
이 구분은 용어 시험을 위한 것이 아니다. “다른 데이터가 보인다”는 말만으로 전달하면 객체 관계 검사, 응답 직렬화, 역할 기반 기능 권한 중 어디를 고쳐야 하는지 다시 조사해야 한다. 문제의 단위를 정확히 말하면 대응 시간이 줄어든다.
프런트엔드 코드에서 보이는 것은 취약점인가, 공격 표면인가
다음 코드를 발견했다고 해 보자.
const workspaceId = router.query.workspaceId;
return api.get('/documents', {
params: { workspaceId },
});
URL에서 받은 값을 API에 전달하고 있으니 위험해 보인다. 하지만 이 코드만으로 BOLA가 확정되지는 않는다.
서버가 토큰에서 현재 사용자의 권한 범위를 구하고, 전달된 workspaceId가 그 범위에 포함되는지 검사할 수 있기 때문이다. 서버가 요청의 workspaceId를 무시하고 토큰의 주체로 조회할 수도 있다. 그런 계약이라면 프런트 코드가 불필요하거나 혼란스럽기는 해도 다른 객체가 노출되지는 않는다.
정적 코드 조사로 확정할 수 있는 것은 보통 여기까지다.
사용자가 통제할 수 있는 객체 식별자가 서버로 전달되고 있으며, 브라우저 코드에는 이를 신뢰할 근거가 없다.
이것은 중요한 공격 표면이다. 그러나 실제 BOLA 여부는 서버의 인가 로직이나 통제된 환경의 요청 결과로 확인해야 한다.
이 구분을 지키지 않으면 두 가지 문제가 생긴다.
- 코드 검색 결과만으로 취약점을 확정해 불필요한 긴급 대응을 만들 수 있다.
- 반대로 화면에서 다른 ID를 선택할 수 없다는 이유로 서버의 권한 문제를 놓칠 수 있다.
프런트엔드 조사는 판결문이 아니라 백엔드 검증을 빠르게 시작할 수 있는 지도여야 한다.
브라우저에 보이는 호스트가 책임 경계는 아니다
처음 문제를 공유하면 대화가 잠시 엇갈릴 수 있다. 프런트엔드 개발자는 브라우저가 호출하는 주소와 그 요청에 포함되는 객체 ID를 전달한다. 백엔드 개발자는 그 주소를 보고 이렇게 되묻는다.
이것은 프런트엔드 호스트인데, 우리가 담당하는 API가 맞나요?
둘 중 한쪽이 틀렸다고 보기는 어렵다. 브라우저는 실제로 프런트엔드 호스트를 호출하고 있지만, 그 요청이 BFF 프록시를 거쳐 별도의 백엔드 API로 전달되고 있을 수 있다. 프런트엔드는 사용자가 들어오는 진입점을 설명했고, 백엔드는 인가 로직을 찾아갈 수 있는 실제 upstream 엔드포인트가 필요했던 것이다.
Browser
└─ FE host / BFF proxy
└─ Backend API
└─ Object-level authorization
└─ Data
이 구조에서 호스트 이름만 보고 “여기까지는 프런트엔드 범위이고, 그 뒤는 우리 범위가 아니다”라고 나누면 정작 객체 접근을 승인하는 경계를 놓치기 쉽다. 반대로 모든 문제를 백엔드에 넘기면서 프록시가 어떤 경로와 인증 정보를 전달하는지 설명하지 않으면 대응이 늦어진다.
먼저 물어야 할 질문은 주소를 누가 소유하는지가 아니다.
이 요청에서 인증된 사용자와 대상 객체의 관계를 최종적으로 판단하는 계층은 어디인가?
보안 이슈를 전달할 때는 브라우저에서 관찰한 URL과 실제 upstream API를 함께 적어야 한다. 브라우저 URL만 있으면 백엔드 개발자가 자신의 서비스와 연결하기 어렵고, upstream API만 있으면 사용자가 어떤 화면과 입력을 통해 요청을 만들 수 있는지가 빠진다.
| 경계 | 함께 전달할 내용 |
|---|---|
| 사용자 진입점 | 어떤 화면과 URL에서 요청이 시작되는가 |
| 브라우저 요청 | 어느 FE/BFF 주소를 호출하는가 |
| 프록시 동작 | 객체 ID와 쿠키·Authorization을 어떻게 전달하는가 |
| 실제 upstream | 어느 백엔드 서비스의 어떤 API가 호출되는가 |
| 인가 지점 | 사용자와 객체의 관계를 어디에서 검사해야 하는가 |
| 조치 범위 | FE 완화, BFF 방어, BE 최종 인가를 각각 누가 맡는가 |
BOLA 대응에서 범위는 호스트 이름으로 나뉘지 않는다. 사용자 요청에서 데이터까지 이어지는 경로와, 객체 접근을 최종 승인하는 계층을 따라가야 한다.
프런트엔드 개발자가 가장 먼저 할 일
1. 식별자의 흐름을 찾는다
객체 식별자가 어디에서 시작해 어디로 전달되는지 연결한다.
- URL path:
/workspaces/{workspaceId} - query string:
?workspaceId=100 - request body:
{ "workspaceId": 100 } - request header
- 이전 API 응답에서 받은 ID
- 브라우저에서 디코딩한 토큰의 클레임(claim)
그리고 각 식별자가 어떤 API의 path, query, body로 들어가는지 기록한다. 같은 객체에 대해 GET, PATCH, DELETE, 엑셀 다운로드 API가 따로 존재할 수 있으므로 화면 단위보다 API 단위로 보는 편이 안전하다.
이때 “화면에 워크스페이스 선택 UI가 없다”는 사실은 안전의 증거가 아니다. 사용자는 개발자 도구, 프록시, 스크립트로 요청을 직접 만들 수 있다. UI가 없는 입력값이 URL이나 상태에 남아 있다면 오히려 복사된 코드나 오래된 계약의 흔적일 수 있다.
2. 확정된 사실과 확인할 가설을 나눈다
조사 결과는 다음처럼 나누는 것이 좋다.
확정된 사실
- 객체 ID가 사용자가 수정할 수 있는 URL에서 온다.
- 프런트엔드는 그 ID를 API 요청에 전달한다.
- 브라우저의 화면 가드 외에는 확인 가능한 방어가 없다.
- 해당 ID를 사용하는 읽기·쓰기·다운로드 API가 존재한다.
백엔드 확인이 필요한 가설
- 서버가 토큰의 주체와 요청 객체의 관계를 검사하지 않는다.
- 다른 사용자의 ID를 넣으면 데이터가 반환된다.
- 읽기는 차단되지만 수정 또는 다운로드는 허용될 수 있다.
“프런트에 검사가 없다”와 “서비스에 BOLA가 있다”를 같은 문장으로 쓰지 않는 것만으로도 커뮤니케이션의 정확도가 크게 올라간다.
3. 백엔드가 바로 검증할 수 있는 형태로 전달한다
백엔드 개발자에게 저장소 전체의 검색 결과만 전달하면 다시 분류하는 시간이 든다. 다음 정보가 한 줄에 모이면 훨씬 빠르게 확인할 수 있다.
| 항목 | 기록할 내용 |
|---|---|
| 기능 | 문서 목록, 문서 상세, 멤버 수정 등 |
| 실제 upstream API | 프런트 호스트나 프록시 주소가 아닌 최종 서버 엔드포인트 |
| 메서드 | GET, POST, PATCH, DELETE |
| 객체 식별자 | 이름과 path/query/body 위치 |
| 식별자 출처 | URL, 폼, 토큰 디코딩, 이전 API 응답 |
| 인증 전달 방식 | 쿠키, Authorization 헤더 등 |
| 확인 상태 | 미검증, 차단 확인, 노출 확인 |
| 기대 정책 | 본인 객체만, 소속 객체 목록, 관리자 전체 접근 등 |
특히 프런트 프록시를 경유한다면 브라우저에 보이는 URL만 전달하지 않는다. 프록시가 실제로 호출하는 upstream 호스트와 경로를 함께 적어야 담당 백엔드와 인가 코드의 위치를 찾을 수 있다.
실제 노출 검증은 어떻게 도울까
검증은 운영 데이터가 아닌 통제된 QA 환경에서, 권한과 소유 관계가 명확한 두 테스트 계정으로 수행하는 것이 기본이다.
- 계정 A가 소유한 객체 A
- 계정 B가 소유한 객체 B
- 계정 A의 정상 세션
먼저 계정 A의 세션으로 객체 A를 조회해 테스트 조건이 정상인지 확인한다. 다음으로 객체 ID만 B로 바꾼다.
GET /api/documents/{object-B}
Authorization: Bearer <token-for-user-A>
기대 결과는 서비스의 은닉 정책에 따라 403, 404, 빈 결과 등이 될 수 있다. 중요한 것은 API의 기대 계약을 먼저 정하고, 객체 B의 데이터가 반환되지 않아야 한다는 점이다.
검증 결과를 남길 때는 다음을 포함한다.
- 사용한 역할과 객체 소유 관계
- 변경한 입력값
- HTTP 상태 코드
- 응답 스키마와 데이터 소유 주체
- 재현 시각과 환경
- 기대한 결과
실제 토큰, 쿠키, 개인정보, 계좌 정보 같은 값은 문서와 메신저에 남기지 않는다. 응답 전체를 복사하기보다 권한 위반을 입증하는 데 필요한 최소 정보만 마스킹해서 남긴다.
읽기 요청으로 문제가 확인됐다고 해서 운영 환경에서 수정과 삭제까지 시험해서는 안 된다. 쓰기 API는 롤백 가능한 QA 데이터와 명시적인 검증 계획이 있을 때만 확인한다.
프런트엔드에서 할 수 있는 조치와 할 수 없는 조치
불필요한 식별자 전달은 제거한다
서버가 세션의 사용자만 대상으로 조회하는 API인데 과거 코드의 workspaceId 파라미터가 남아 있을 수 있다. 화면에서 설정할 방법도 없고 서버 계약에도 필요하지 않다면 제거하는 편이 낫다.
사용자가 통제할 수 있는 입력을 줄이면 공격 표면과 계약의 모호함이 함께 줄어든다. 서버가 파라미터를 무시하고 있더라도 “이 값으로 대상을 고를 수 있다”는 잘못된 사용법이 다른 코드로 복제되는 것을 막을 수 있다.
화면 가드는 보완책으로 사용한다
라우트의 ID와 로그인 사용자의 ID가 다르면 화면 진입을 막는 코드는 정상 사용자의 실수와 잘못된 링크 이동을 줄여 준다. 권한 없는 버튼을 숨기는 것도 사용자 경험에 필요하다.
하지만 이를 BOLA 수정 완료로 기록해서는 안 된다.
if (routeWorkspaceId !== session.workspaceId) {
return <AccessDenied />;
}
이 코드는 브라우저 번들에 있고 사용자가 우회할 수 있다. API를 직접 호출하는 공격에는 효력이 없다. 버튼을 숨기는 것은 UX 정책이고, 객체를 지키는 것은 서버의 인가 정책이다.
FE 팀이 소유한 BFF는 서버 경계로 다룬다
프런트엔드 팀이 Next.js Route Handler나 별도의 BFF를 운영한다면 이야기가 조금 달라진다. 브라우저 코드와 달리 BFF는 사용자가 수정할 수 없는 서버 경계다. 세션 검증, 허용된 upstream 경로 제한, 요청 헤더 정리, 리다이렉트 제한 같은 방어를 적용할 수 있다.
다만 범용 프록시에서 URL의 ID와 토큰의 클레임을 단순 비교하는 것만으로 모든 BOLA를 해결할 수는 없다. 한 사용자가 여러 조직에 속하거나, 위임 권한을 가지거나, 역할별로 접근 가능한 객체 집합이 달라질 수 있기 때문이다. 객체의 소유 관계와 업무 정책을 가장 잘 아는 서비스가 최종 인가를 수행해야 한다.
BFF의 검사는 방어층이 될 수 있지만, 백엔드 데이터 서비스의 객체 단위 인가를 대체해서는 안 된다.
백엔드와 합의할 해결 방향
가장 단순한 계약은 클라이언트가 대상 사용자를 고르지 않게 만드는 것이다.
GET /me/documents
또는 식별자 없이 호출하고 서버가 세션에서 범위를 구할 수 있다.
GET /documents
Authorization: Bearer <token>
하지만 모든 API를 /me로 만들 수는 없다. 관리자는 여러 워크스페이스를 조회해야 하고, 한 사용자가 여러 조직의 객체를 다룰 수도 있다. 이 경우 서버는 단순히 token.workspaceId === request.workspaceId만 비교할 것이 아니라 현재 사용자에게 적용되는 정책과 객체 관계를 평가해야 한다.
const actor = requireAuthenticatedActor(request);
const document = await documentRepository.findById(input.documentId);
authorization.assertCanReadDocument(actor, document);
return document;
핵심은 조회한 뒤 화면에서 걸러 내는 것이 아니라, 데이터를 반환하기 전에 서버가 해당 작업과 객체의 관계를 확인하는 것이다.
해결 뒤에는 최소한 다음 회귀 테스트가 남아야 한다.
| 세션 | 대상 객체 | 작업 | 기대 결과 |
|---|---|---|---|
| 사용자 A | A 소유 객체 | 조회 | 성공 |
| 사용자 A | B 소유 객체 | 조회 | 거부 |
| 사용자 A | B 소유 객체 | 수정 | 거부 |
| 사용자 A | B 소유 객체 | 삭제 | 거부 |
| 관리자 | 정책상 허용된 B 객체 | 조회 | 성공 |
| 비로그인 | 임의 객체 | 조회 | 거부 |
권한 정책이 복잡할수록 테스트도 역할 이름만 보는 것이 아니라 사용자·조직·객체·행위의 조합을 표현해야 한다.
긴급 상황에서 FE가 전달할 수 있는 보고서
BOLA가 의심될 때 프런트엔드 개발자가 다음 정도로 정리하면 백엔드가 첫 검증에 드는 시간을 크게 줄일 수 있다.
[현상]
- 문서 목록에서 workspaceId가 query parameter로 전달됩니다.
[확정된 코드 경로]
- URL query → 목록 조회 훅 → BFF proxy → document API
- 브라우저에서는 로그인 사용자의 workspaceId와 대조하지 않습니다.
[백엔드 확인 요청]
- 사용자 A의 세션으로 workspaceId B를 지정했을 때 B의 데이터가 반환되는지 확인이 필요합니다.
- 기대 결과는 권한 거부 또는 빈 결과입니다.
[영향 후보]
- 목록 조회
- 상세 조회
- 파일 다운로드
- 수정/삭제 API는 QA 데이터에서 별도 검증이 필요합니다.
[FE 즉시 조치 후보]
- 사용되지 않는 workspaceId query 전달 제거
- 잘못된 화면 진입을 막는 UX 가드 추가
- 단, 서버 객체 단위 인가 수정이 완료 조건입니다.
여기에는 “BOLA 확정”과 “BOLA 가능성”을 구분하는 상태값도 필요하다.
- 공격 표면 확인: 사용자가 통제할 수 있는 ID가 API로 전달됨
- 서버 차단 확인: 다른 객체 ID를 사용한 요청이 거부됨
- 노출 확인: 다른 객체의 데이터가 반환되거나 변경됨
- 수정 확인: 서버 인가 수정과 회귀 테스트가 완료됨
이 상태를 섞지 않으면 심각도를 낮추지도, 검증 전부터 과장하지도 않으면서 빠르게 대응할 수 있다.
프런트엔드가 BOLA를 막는다는 말 대신
프런트엔드 개발자가 BOLA 의심 지점을 발견했을 때 가장 먼저 떠올릴 해결책은 화면에서 ID를 검사하는 것이다. 눈앞의 코드에서 시작하니 자연스럽다.
하지만 보안 경계는 사용자가 바꿀 수 없는 곳에 있어야 한다. 브라우저에서 다른 ID를 보내지 않게 만드는 것과 서버가 다른 ID를 받아도 거부하는 것은 전혀 다른 보장이다.
프런트엔드는 BOLA의 최종 해결 주체가 아니다. 대신 다음 일을 아주 잘할 수 있다.
- 사용자 입력에서 API까지 이어지는 식별자 흐름을 찾는다.
- 같은 식별자를 사용하는 읽기·쓰기·다운로드 API를 묶는다.
- 확정된 코드 사실과 서버 검증이 필요한 가설을 분리한다.
- 실제 upstream API와 재현 조건을 정리한다.
- 불필요한 클라이언트 입력과 범용 프록시 표면을 줄인다.
- 백엔드 수정 뒤 사용자 시나리오와 회귀 테스트를 함께 확인한다.
좋은 대응은 “프런트에서도 한 번 막았습니다”로 끝나지 않는다. 데이터와 객체 관계를 소유한 서버가 인가를 고칠 수 있도록 정확한 질문과 증거를 전달하고, 그 수정이 실제 사용자 경로에서도 유지되는지 확인하는 것이다.
프런트엔드의 역할은 서버의 인가를 흉내 내는 것이 아니라, 깨진 경계를 빠르게 드러내고 올바른 팀이 빠르게 고치도록 만드는 것이다.
서버 경계가 프런트엔드 추상화 뒤에 숨어 있을 때 무엇을 확인해야 하는지는 Next.js 보안 패치, 버전만 올리고 끝내지 않기에서도 다뤘다. RSC와 Server Action의 인증·인가 경계를 더 좁게 살펴보려면 RSC 보안 사태 이후, FE가 먼저 볼 것으로 이어서 볼 수 있다.
대응 체크리스트
- 사용자가 통제할 수 있는 객체 ID의 출처를 찾았는가?
- ID가 들어가는 path, query, body, header를 확인했는가?
- 브라우저 URL과 실제 upstream API를 구분했는가?
- 같은 ID를 사용하는 조회·수정·삭제·다운로드 API를 확인했는가?
- 프런트 코드에서 확인한 사실과 서버에 대한 가설을 나눴는가?
- QA 계정 A와 객체 B로 객체 단위 인가를 검증했는가?
- 실제 인증 정보와 민감 데이터를 기록에서 제거했는가?
- 화면 가드를 최종 보안 조치로 오해하지 않았는가?
- 서버 수정과 객체 단위 회귀 테스트를 완료 조건으로 삼았는가?
- BOLA, BOPLA, BFLA 중 실제로 깨진 권한 단위를 구분했는가?