client_id/client_secret으로 토큰을 발급했고, 시크릿은 bcrypt로 해싱 저장, 토큰은 15분 만료 + refresh token 미사용(스펙 권장)으로 설계했습니다.외부 ERP 기관이 우리 서비스의 API를 호출해야 하는 상황이었습니다. 여기서 인증 주체는 사람이 아니라 ERP 시스템 그 자체입니다. 로그인 화면을 띄우고 사용자가 동의 버튼을 누르는 흐름(Authorization Code)이 낄 자리가 없습니다.
가장 먼저 떠오른 건 "그냥 API Key 하나 발급해서 헤더에 넣게 하면 되지 않나?"였습니다. 하지만 API Key 단일 방식은 몇 가지가 걸렸습니다.
Client Credentials Grant: 리소스 소유자(사람)가 없고 클라이언트 자신이 곧 주체인 경우를 위한 흐름입니다. 클라이언트가 자기 자격증명(
client_id+client_secret)만으로 access token을 받습니다. RFC 6749 §4.4에 정의돼 있습니다.
정리하면 인증 주체가 "사람"이면 Authorization Code(+PKCE), "시스템"이면 Client Credentials입니다. 우리는 후자였고, 우리가 토큰을 발급하는 인가 서버 역할을 맡았습니다.
[ERP 시스템] [우리 서비스 = 인가 서버 + 리소스 서버] │ ① client_id + client_secret (+scope) │ ├────────────── POST /oauth/token ───────►│ 자격증명 검증(bcrypt) │ │ scope 확정 │ ② access_token, expires_in, scope │ │◄──────────────────────────────────────── │ │ │ │ ③ Authorization: Bearer <token> │ ├───────────── GET /api/... ──────────────►│ 토큰 검증 → 응답 │◄──────────────────────────────────────── │
client_secret은 사실상 비밀번호입니다. DB에 평문으로 저장하면 DB가 새는 순간 모든 연동 기관의 자격증명이 그대로 털립니다. 그래서 발급 시점에 bcrypt로 해싱해 저장하고, 원본 시크릿은 발급 응답에서 딱 한 번만 보여주는 방식으로 갔습니다.
bcrypt를 택한 이유는 단순히 해시가 아니라 의도적으로 느린(work factor가 있는) 해시이기 때문입니다. SHA-256 같은 범용 해시는 너무 빨라서, 시크릿을 탈취당했을 때 브루트포스로 원본을 역산하기 쉽습니다. bcrypt는 cost를 높여 대입 공격의 비용을 끌어올립니다. (Argon2도 함께 검토했는데, 팀에 이미 검증된 bcrypt 구성이 있어 그대로 갔습니다.)
// 발급 시: 원본 시크릿은 이 응답에서만 노출, 저장은 해시만 String rawSecret = SecretGenerator.generate(); // 높은 엔트로피 랜덤 String hashed = passwordEncoder.encode(rawSecret); // bcrypt clientRepository.save(new OAuthClient(clientId, hashed, scopes)); // 토큰 요청 시: 평문 비교가 아니라 해시 검증 if (!passwordEncoder.matches(requestSecret, client.getHashedSecret())) { throw new InvalidClientException(); }
Access token 유효기간은 15분으로 짧게 잡았습니다. 토큰이 새더라도 유효 시간이 짧으면 피해 창(window)이 줄어듭니다.
여기서 자연스럽게 나오는 질문이 "그럼 refresh token은?"입니다. 그런데 Client Credentials Grant에서는 refresh token을 발급하지 않는 게 스펙 권장 사항입니다(RFC 6749 §4.4.3, "A refresh token SHOULD NOT be included"). 이유가 명쾌합니다. 클라이언트가 이미 client_id/client_secret을 들고 있으니, 토큰이 만료되면 그 자격증명으로 언제든 새 토큰을 받으면 됩니다. refresh token은 사용자가 다시 로그인하지 않게 하려는 장치인데, 여기엔 애초에 사용자가 없습니다.
그래서 갱신 책임은 클라이언트(ERP) 쪽으로 넘겼습니다. 매 요청마다 토큰을 새로 받는 낭비도, 만료된 토큰으로 실패하는 것도 피하려고, 만료 60초 전에 선제적으로 재발급하는 방식을 인터셉터에 넣도록 가이드했습니다.
// 클라이언트(호출자) 측 인터셉터 - 만료 임박하면 미리 갱신 public Request intercept(Request req) { if (token == null || token.expiresWithin(Duration.ofSeconds(60))) { token = requestNewToken(); // client_id/secret으로 재발급 } return req.withHeader("Authorization", "Bearer " + token.value()); }
이 구조의 장점은 서버가 refresh token의 저장·회전·폐기라는 복잡도를 아예 떠안지 않아도 된다는 점입니다. 상태를 줄이는 방향이 곧 보안 표면을 줄이는 방향이었습니다.
모든 기관이 모든 API를 호출할 수 있으면 안 됩니다. 그래서 키(클라이언트) 발급 단계에서 그 기관이 가질 수 있는 scope를 확정하고, 토큰을 발급할 때 실제로 부여된 scope를 응답에 함께 실어 보냈습니다.
POST /oauth/token { "grant_type": "client_credentials", "scope": "erp:read erp:invoice" } // 200 OK — 실제 부여된 scope를 명시해 되돌려줌 { "access_token": "...", "token_type": "DPoP", "expires_in": 900, "scope": "erp:read erp:invoice" }
요청한 scope와 실제 부여된 scope를 응답에서 분리해 명시하면, 클라이언트가 "내가 요청한 것 중 무엇이 실제로 허용됐는지"를 추측하지 않아도 됩니다. 권한을 좁게 요청하지 않았더라도 서버가 발급 단계에서 정의된 범위로 잘라내 돌려줍니다.
여기까지 오면 남는 불안은 Bearer 토큰의 본질적 한계입니다. Bearer는 말 그대로 "소지자(bearer) 토큰"이라, 가진 사람이 임자입니다. 중간에서 토큰만 가로채면 그대로 재사용할 수 있습니다.
그래서 DPoP(Demonstrating Proof of Possession, RFC 9449) 를 도입하기로 했습니다. 핵심 아이디어는 토큰을 특정 키 쌍에 묶어(sender-constrained), "이 토큰의 진짜 주인만이 이 개인키로 서명할 수 있다"는 걸 매 요청마다 증명하게 하는 것입니다.
동작은 이렇습니다.
DPoP 헤더에 실어 보냅니다. 이 proof에는 HTTP 메서드·URL·타임스탬프 등이 들어 있어 재사용이 어렵습니다.DPoP: eyJ... (개인키로 서명된 proof JWT) Authorization: DPoP <access_token> // token_type이 Bearer가 아니라 DPoP
다만 DPoP는 클라이언트에 키 관리·proof 서명이라는 부담을 지웁니다. 모든 연동 기관이 당장 대응하기 어려울 수 있어, 설정 프로퍼티로 사용 여부를 선택하게 했습니다. DPoP를 켠 기관은 토큰 발급 단계에서 공개키/개인키 기반 서명 흐름을 타고, 끈 기관은 기존 Bearer로 동작합니다. 보안을 원하는 만큼 켤 수 있는 선택형 설계로 둔 것입니다.
이 작업에서 가장 크게 남은 건 특정 라이브러리 사용법이 아니라, 인증·인가에는 상황별로 이미 정립된 표준이 계층처럼 쌓여 있다는 감각이었습니다.
처음에는 "어떤 방식을 쓸까"를 고민했지만, 결국 질문이 "이 상황에서 표준은 무엇을 권하고, 왜 그런가"로 바뀌었습니다. Client Credentials가 refresh token을 안 주는 이유, Bearer의 한계를 DPoP가 어떻게 메우는지 — 표준의 "왜"를 이해하고 나니 설계 결정이 훨씬 단단해졌습니다.