파트너사 ERP 가 저희 마켓플레이스에 공고를 등록하고 낙찰을 처리합니다. 그 관문이 Partner API 이고, 이 글은 그 v2 의 인증을 처음 보는 사람 기준으로 풀어 쓴 기록입니다. 표준을 따라가는 길이 왜 결국 더 쉬웠는지, 그 과정에서 제가 잘못 조인 상수 하나가 어떻게 정상 클라이언트를 튕겨 냈는지도 함께 적었습니다.
client_credentials 그랜트입니다. client_id/client_secret 으로 15분짜리 JWT 를 받고, 이후 모든 호출에 Authorization: Bearer <token> 을 붙입니다.client_secret 을 받아 주면 비대칭 인증으로 바꾼 이득이 사라집니다.5 라는 상수 하나가 표준을 지킨 클라이언트만 골라서 401 을 내던 버그였습니다.결론부터 적으면, 고정 API 키는 "누가 호출했나" 만 알려 주고 나머지를 전부 못 알려 줍니다.
v1 의 인증은 단순했습니다. 파트너에게 키를 하나 발급하고, 그 키를 헤더에 실어 보내면 통과입니다. 만들기 쉽고 파트너도 붙이기 쉽습니다. 문제는 운영에 들어가면서 하나씩 드러납니다.
v2 에서는 이 네 가지를 각각 다른 장치로 나눠 담았습니다. 새로 발명하지 않고 OAuth 2.0 이 이미 정해 둔 자리에 넣었습니다. 표준을 고른 이유는 우아함이 아니라 게으름입니다. 파트너 쪽 개발자가 쓰는 언어에는 이미 OAuth 클라이언트 라이브러리가 있고, 저희가 규격을 지키면 그 라이브러리가 그대로 붙습니다. 규격을 살짝 어기면 그 순간부터 파트너마다 "저희 API 는 이렇게 다릅니다" 문서를 써야 합니다.
v2 인증은 두 단계입니다.
POST /oauth/token 에 client_id/secret 을 보내고 access token 을 받습니다.Authorization 헤더에 실어 보냅니다.bash# 1) 토큰 발급 — Basic 헤더에 자격증명을 담는 방식(RFC 6749 §2.3.1) curl -X POST https://api.example.com/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=client_credentials \ -d scope="bids:read bids:write"
json{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6ImF0K2p3dCJ9...", "token_type": "Bearer", "expires_in": 900, "scope": "bids:read bids:write" }
bash# 2) 이후 호출 — 발급받은 토큰을 그대로 싣습니다 curl https://api.example.com/v2/bids \ -H "Authorization: Bearer $ACCESS_TOKEN"
여기서 이미 v1 의 문제 두 개가 풀립니다. expires_in: 900 은 15분입니다. 토큰이 새어도 15분 뒤에는 죽습니다. scope 는 그 토큰이 할 수 있는 일의 목록입니다. bids:read 만 받은 토큰으로 낙찰 API 를 부르면 403 입니다.
왜 15분인지 자주 질문을 받습니다. 짧을수록 유출 창이 작아지고, 대신 재발급 호출이 늘어납니다. 파트너 ERP 는 대체로 배치성 호출이라 15분마다 한 번 더 부르는 비용이 문제가 되지 않습니다. 사람이 쓰는 SPA 라면 다른 판단이 필요합니다.
자격증명을 보내는 자리는 두 곳입니다. Authorization: Basic 헤더(client_secret_basic)와 본문 필드(client_secret_post). 둘 다 표준이라 둘 다 받습니다. 다만 두 곳에 동시에 실어 보내면 거절합니다. 어느 쪽으로 인증됐는지 모호해지는 요청은 통과시키지 않는 편이 낫습니다.
typescriptif (basic && hasBody) { throw new OAuth2Exception( "invalid_request", "client credentials provided by both Basic and body; use one method", ); }
access token 은 서명된 JWT 입니다. 저희는 토큰 안에 "이 호출자가 무엇을 할 수 있는가" 를 미리 넣어 둡니다. 그래야 엣지(partner-api)가 매 요청마다 DB 를 다시 묻지 않습니다.
typescriptconst payload: PartnerTokenPayload = { sub: input.auth.clientId, // 누구인가 (= client_id) jti: randomUUID(), // 이 토큰의 고유 번호 (폐기 목록의 키) scope, // 무엇을 할 수 있나 buyerId: ..., // 누구를 대행하나 allowedIps: ..., // 어느 IP 에서 쓸 수 있나 cnf: ..., // 어느 키에 묶여 있나 (DPoP) };
jti 가 왜 붙었는지는 따로 짚어 둘 만합니다. JWT 는 서명만 맞으면 검증이 통과하므로, 만료 전에 끊을 방법이 원래 없습니다. 고유 번호가 있으면 "이 번호는 무효" 라는 폐기 목록을 둡니다. 그래서 v2 에는 표준 엔드포인트 두 개가 더 있습니다.
POST /oauth/revoke (RFC 7009): 유출을 확인했을 때 15분을 기다리지 않고 끊습니다.POST /oauth/introspect (RFC 7662): 지금 이 토큰이 살아 있는지, 스코프가 무엇인지 확인합니다.두 엔드포인트에는 재미있는 규칙이 하나 있습니다. 남의 토큰을 물어봐도 이유를 알려 주지 않습니다. 폐기 요청은 위조된 토큰이든 남의 토큰이든 조용히 200 을 돌려주고, 검사 요청은 전부 {"active": false} 로 수렴합니다. 만약 "그건 다른 클라이언트의 토큰입니다" 라고 친절하게 답하면, 이 엔드포인트가 "그 토큰이 실재하는가" 를 확인해 주는 조회창이 됩니다. RFC 7009 §2.2 와 RFC 7662 §4 가 그렇게 하라고 못 박아 둔 이유가 이것입니다.
typescript// 남의 토큰을 폐기할 수는 없습니다. 400 을 내면 "그 토큰이 실재한다" 는 사실이 새므로 조용히 뭅니다. if (payload.sub !== input.auth.clientId) return;
client_secret 은 결국 공유 비밀입니다. 파트너도 갖고 있고 저희도 (해시 형태로) 갖고 있습니다. 더 강한 방식이 표준에 있습니다. RFC 7523 의 private_key_jwt 입니다. 파트너가 개인키로 짧은 JWT 를 서명해서 보내고, 저희는 등록된 공개키로 검증합니다. 비밀이 네트워크를 건너가지 않습니다.
여기서 설계 판단이 하나 필요했습니다. 한 파트너가 두 방식을 다 쓸 수 있게 열어 둘까요?
닫았습니다. 공개키가 등록된 키에 client_secret 이 오면 거절합니다.
typescriptif (auth.kind === "secret") { const verification = await this.backend.verifyPartnerKey( auth.clientId, auth.clientSecret, ); if (!verification.valid) throw invalidClient(); if (verification.publicJwk != null) { throw new OAuth2Exception( "invalid_client", "this client must authenticate with private_key_jwt", HttpStatus.UNAUTHORIZED, ); } return verification; }
이유는 한 줄입니다. 공격자는 언제나 약한 쪽을 고릅니다. 두 방식을 병행으로 열어 두면, 비대칭 키로 올린 보안 수준이 남아 있는 secret 경로의 수준으로 내려앉습니다. 그래서 방식 선택권을 요청에서 빼고 키 설정으로 옮겼습니다. 요청은 "무엇을 들고 왔는지" 만 말하고, 그것이 허용된 방식인지는 서버가 판정합니다.
Bearer 토큰의 약점은 이름 그대로입니다. 들고 있는 사람(bearer)이면 씁니다. 로그에 새거나 프록시에서 새면 그걸 주운 쪽이 그대로 호출합니다.
DPoP(RFC 9449)가 이걸 막습니다. 원리는 어렵지 않습니다.
jkt)을 토큰 안에 박아 둡니다.DPoP 헤더에 실습니다. 서버는 지문이 일치하는지 봅니다.이제 토큰만 훔쳐도 못 씁니다. proof 를 만들 개인키가 없기 때문입니다. 이렇게 토큰을 특정 발신자에 묶는 성질을 sender-constrained 라고 부릅니다.
proof 안에는 HTTP 메서드(htm), 요청 URL(htu), 발급 시각(iat), 고유 번호(jti), 그리고 그 토큰의 해시(ath)가 들어갑니다. 그래서 A 엔드포인트용 proof 를 B 엔드포인트에 재사용할 수 없습니다.
여기까지가 설계입니다. 실제로는 표준 문서를 읽으면서도 네 번 헛디뎠습니다.
DPoP proof 는 신선해야 합니다. 오래된 proof 를 받아 주면 재사용 창이 그만큼 열립니다. 그래서 저는 iat 수용 창을 좁게 잡았습니다. 미래 방향 오차는 5초까지만 허용했습니다. 클라이언트 시계가 서버보다 앞설 수 있으니 약간은 흡수하고, 그 이상은 거절한다는 판단이었습니다.
FAPI 2.0 Security Profile §5.3.2.1 R13 을 읽고 그게 틀렸다는 걸 알았습니다.
shall accept JWTs with an
iatornbftimestamp between 0 and 10 seconds in the future but shall reject JWTs with aniatornbftimestamp greater than 60 seconds in the future.
즉 미래 010초는 받아 주어야 합니다. 5초로 조인 서버는 610초 앞선 시계를 가진 클라이언트를 401 로 튕깁니다. 규격대로 만든 클라이언트만 골라서 실패하는 상호운용 버그입니다. 방어가 아니라 사고였습니다.
10 으로 올렸습니다. 상한 60초 요구도 함께 만족합니다(더 좁으므로).
typescriptexport const DPOP_FRESHNESS_SECONDS = 60; // 과거 방향 수용 창 export const DPOP_CLOCK_SKEW_SECONDS = 10; // 미래 방향 수용 창 (FAPI 2.0 R13)
배운 점이 두 개 있습니다. 첫째, 보안 상수를 "더 좁게" 잡는 것이 항상 더 안전한 선택은 아닙니다. 좁혀서 정상 트래픽을 깨면 그건 가용성 사고입니다. 둘째, 5초든 10초든 창을 좁히는 것만으로 재사용을 막으려던 접근 자체가 약했습니다. 진짜 방어는 다음 항목입니다.
jti 를 검증하지 않으면 재사용 방어는 성립하지 않습니다#RFC 9449 §4.2 는 proof 에 jti 를 요구하고, §11.1 은 그 jti 를 기억해서 재사용을 막으라고 합니다. 처음 구현에서 저는 jti 를 읽지 않았습니다. 그러면 어떻게 되는지 적어 보면 명확합니다. jti 없는 proof 가 그대로 통과하고, 재사용 방지 캐시는 기억할 키가 없습니다. 캐시는 있는데 아무것도 막지 못하는 상태였습니다.
지금은 없으면 거절합니다.
typescriptconst jti = payload.jti; if (typeof jti !== "string" || jti === "") { throw new DpopError("DPoP proof missing jti"); }
검증 순서에도 이유가 하나 붙어 있습니다. 재사용 검사는 맨 마지막에 합니다. 순서를 앞으로 당기면, 공격자가 서명이 틀린 proof 로 남의 jti 를 먼저 캐시에 선점해서 진짜 요청을 막을 수 있습니다. 캐시 오염 DoS 입니다. 캐시 키에는 jti 만 넣지 않고 키 지문을 섞습니다. 클라이언트끼리 jti 가 우연히 겹쳐도 서로를 막지 않게 하려는 목적입니다.
typescriptif (input.replay.seen(`${jkt}:${jti}`, expiresAtMs)) { throw new DpopError("DPoP proof replayed"); }
여기가 가장 조용한 구멍이었습니다.
키 설정에 "이 파트너는 DPoP 필수" 플래그가 있습니다. 그런데 발급 단계에서 proof 를 받지 않으면 cnf(지문)가 없는 토큰이 나갑니다. 그 토큰으로 요청이 오면 가드는 대조할 지문이 없습니다. 대조 대상이 없으면 공격자가 자기 키로 만든 proof 도 통과합니다. 형식만 DPoP 이고 방어는 0 입니다.
두 겹으로 막았습니다. 발급 단계에서 proof 없는 요청을 거절하고, 가드에서도 지문 없는 토큰을 proof 검증 이전에 떨어뜨립니다.
typescript// 발급 시점 — proof 없이 발급하면 sender-constraint 가 성립하지 않습니다. if (verification.dpopRequired && !input.dpopProof) { throw new OAuth2Exception( "invalid_dpop_proof", "this client requires a DPoP proof at the token endpoint", ); }
typescript// 요청 시점 — 게이트 도입 전에 발급된 토큰이 남아 있을 수 있으니 여기서도 막습니다. if (!dpopBound) { throw new PartnerProblemException({ status: 401, title: 'DPOP_REQUIRED', ... }); }
정상 클라이언트는 토큰을 재발급받으면 즉시 복구됩니다. 이 클래스의 버그는 테스트가 전부 초록불인 상태로 숨어 있습니다. "DPoP 를 켠 키가 proof 로 호출하면 통과한다" 는 테스트는 통과하고, proof 없이도 통과한다 는 사실은 아무도 물어보지 않기 때문입니다. 보안 기능은 "되는 경우" 뿐 아니라 "안 돼야 하는 경우" 를 테스트해야 합니다.
토큰 엔드포인트는 비밀 추측 공격의 정문이라 조여야 합니다. IP 축은 분당 5회로 묶었습니다. 문제는 계정 축입니다.
client_id 별 요청 수를 세면 이런 일이 가능합니다. 공격자가 남의 client_id 로 아무 비밀이나 넣어 헛요청을 보냅니다. 그 파트너의 카운터가 먼저 차고, 정작 그 파트너의 정상 요청이 429 로 막힙니다. 보안 장치가 서비스 거부 도구가 됩니다.
그래서 계정 축은 요청 수가 아니라 실패 수를 셉니다. 인증에 성공한 요청은 카운터를 늘리지 않으므로, 정상 파트너는 남이 아무리 두드려도 잠기지 않습니다.
토큰을 받았다고 무엇이든 할 수 있는 건 아닙니다. v2 는 두 축을 따로 봅니다.
무엇을 할 수 있나(capability) 는 scope 입니다. bids:read, awards:write, contracts:read 처럼 도메인과 동작을 붙인 문자열이고, 키에 부여된 목록 안에서만 발급됩니다. 요청이 그 범위를 벗어나면 invalid_scope 로 400 입니다. 요청에 scope 를 아예 안 쓰면 그 키가 가진 전체를 받습니다.
여기서 한 번 되돌린 판단이 있습니다. 예전에는 키의 scope 목록이 비어 있으면 전체를 부여하는 폴백이 있었습니다. 지웠습니다. 빈 값이 "전부 허용" 을 뜻하면, 설정을 깜빡한 키가 가장 강한 키가 됩니다. 지금은 빈 목록이면 부여 0 입니다. 기본은 거부입니다.
누구를 대행하나(delegation) 는 별개 축입니다. 처음에는 이것도 scope 문자열에 섞어 contracts:any-member 같은 이름을 썼습니다. 도메인이 하나 늘 때마다 X:any-member 가 하나씩 붙는 것을 보고 축을 분리했습니다. 지금은 키 설정에 대행 범위가 따로 있습니다.
그리고 엣지에서 하나를 fail-closed 로 잠갔습니다. 대행 범위가 설정되지 않은 키는 v2 진입점에서 403 입니다.
typescriptif (!hasDelegation && !payload.buyerId && !payload.sandboxBuyerId) { throw new PartnerProblemException({ status: 403, title: "UNBOUND_PARTNER_KEY", detail: "this API key has no delegation configured (BOLA fail-closed)", }); }
범위가 없는 키를 통과시키면 뒷단이 "제한 없음" 으로 해석할 수 있고, 그 순간 파트너 하나가 다른 파트너의 데이터에 닿습니다. OWASP API Security 의 API1(BOLA)이 정확히 이 클래스입니다. 설정이 비어 있을 때는 열지 않고 닫습니다.
다만 판정을 두 곳에 나눠 두지는 않았습니다. 엣지는 "설정이 있나" 만 봅니다. "이 회원이 그 범위 안인가" 는 회원 정보를 읽어야 알 수 있어서 도메인 authority 인 backend 가 판정합니다. 같은 판단을 두 곳에서 하면 두 곳이 갈립니다.
v2 인증은 결국 표준 조각을 제자리에 끼운 결과입니다. 새로 만든 것은 거의 없습니다.
| 무엇을 막나 | 장치 | 근거 |
|---|---|---|
| 오래 사는 자격증명 | 15분 access token | RFC 6749 §4.4 |
| 유출된 토큰 | revoke / introspect | RFC 7009, RFC 7662 |
| 공유 비밀 자체 | private_key_jwt | RFC 7523 |
| 토큰 탈취 후 재사용 | DPoP sender-constraint | RFC 9449 |
| 과도한 권한 | scope (기본 거부) | RFC 6749 §3.3 |
| 파트너 간 데이터 접근 | 대행 범위 fail-closed | OWASP API1 |
| 비밀 추측·계정 잠금 | 실패 수 기준 rate limit | RFC 6749 §3.2 |
jti 가 필수인 이유는 전부 스펙 본문에 적혀 있습니다. 요약본만 읽고 구현하면 저처럼 형식만 맞춘 방어를 만듭니다.client_credentials 하나만 지원합니다. 서버 대 서버 연동만 상정한 API 라 사용자 위임(authorization_code)이 필요 없었습니다. 사람이 로그인하는 흐름이라면 PKCE 를 포함한 다른 설계가 필요합니다.jti 캐시로 막고 있고, FAPI 2.0 이 nonce 를 요구하지 않기 때문입니다.// Comments