API 키는 왜 다시 볼 수 없을까: 발급·검증·권한이 갈리는 자리
GitHub 에서 토큰을 만들면 화면에 한 번 뜨고 다시는 볼 수 없습니다. Stripe 도 AWS 도 같습니다. 복사를 놓치면 방법이 없고, 새로 만들라는 안내만 나옵니다.
그런데 그 서버는 잠시 뒤부터 내가 보낸 키가 맞는지 판정해 줍니다. 맞는지 아는데 보여주지는 못합니다. 두 문장이 같이 서려면 중간에 무언가 빠져 있습니다.
서버가 키를 갖고 있다는 오해
흔히 이렇게 이해합니다. 서버가 발급한 키를 목록에 적어 두고, 요청이 오면 그 목록에서 찾아 맞춰 본다고요. 이 그림이 맞다면 다시 보여주지 못할 이유가 없습니다. 목록에 있으니까요.
실제로는 서버가 키를 갖고 있지 않습니다. 가진 것은 키의 지문뿐입니다.
저희가 만든 LLM 게이트웨이도 같은 규약을 씁니다. 키 원문은 dlk_live_ 뒤에 62진수 32자가 붙은 문자열이고, 이 원문은 발급 응답에서 딱 한 번 나가고 어디에도 저장되지 않습니다. 데이터베이스에 남는 것은 두 가지입니다. 앞 16자와 원문의 SHA-256 해시입니다.
해시는 한쪽으로만 갑니다. 원문을 넣으면 항상 같은 해시가 나오지만, 해시에서 원문을 되돌릴 수는 없습니다. 그래서 서버는 들어온 키를 해시로 바꿔 저장된 해시와 같은지만 봅니다. 같으면 맞는 키입니다. 이 판정에 원문은 필요하지 않습니다.
여기서 아까의 모순이 풀립니다. 서버는 맞는지 알 수 있고, 동시에 보여줄 수 없습니다. 지문으로 사람을 알아볼 수는 있어도 지문에서 얼굴을 복원할 수는 없는 것과 같습니다.
앞 16자를 따로 저장하는 이유
해시만 있으면 될 것 같은데 앞자리를 따로 떼어 두는 데는 이유가 있습니다.
해시는 원문이 1글자만 달라도 완전히 다른 값이 나옵니다. 그래서 해시로는 "이 키가 어느 행인지" 미리 좁힐 수가 없고, 들어온 키를 저장된 모든 행과 하나씩 비교해야 합니다. 키가 10개면 괜찮지만 10만 개가 되면 요청 하나에 10만 번 비교가 붙습니다.
앞 16자는 dlk_live_ 아홉 자에 무작위 일곱 자를 더한 값이고, 서로 겹치지 않게 유니크로 잡혀 있습니다. 그래서 들어온 키의 앞 16자로 데이터베이스에서 한 행만 바로 집어 옵니다. 그 한 행의 해시와 비교하면 끝입니다. 키가 몇 개로 늘어나도 조회 비용이 그대로입니다.
관리 화면에서 키 목록에 dlk_live_a1b2c3d4… 처럼 앞부분만 보이는 것도 여기서 나옵니다. 그건 가려 놓은 것이 아니라 실제로 서버가 가진 전부입니다.
salt 를 쓰지 않는 자리
비밀번호를 다룰 때는 해시에 salt 를 섞고 bcrypt 같은 느린 함수를 쓰라고 배웁니다. 여기서는 그렇게 하지 않았습니다.
salt 와 느린 해시는 사람이 고른 비밀번호를 지키려고 있는 장치입니다. 사람은 password1 같은 것을 고르기 때문에, 공격자가 흔한 비밀번호를 미리 해시해 표로 만들어 두면 뚫립니다. salt 는 그 표를 무용하게 만들고, 느린 함수는 표를 만드는 비용 자체를 올립니다.
API 키는 사람이 고르지 않습니다. 62자 알파벳에서 32자를 난수로 뽑으니 경우의 수가 10의 57승 규모입니다. 미리 표로 만들 수 있는 크기가 아닙니다. 지켜야 할 위협이 애초에 없습니다.
그리고 salt 를 쓰면 위에서 만든 O(1) 조회가 무너집니다. salt 는 행마다 다르므로 어느 행의 salt 를 쓸지 알려면 그 행을 먼저 찾아야 하는데, 찾으려면 다시 전 행을 돌아야 합니다. 저는 이 자리가 "보안 관행을 외워서 적용하면 오히려 나빠지는" 대표적인 예라고 봅니다. 무엇으로부터 지키는 장치인지 먼저 보면 답이 갈립니다.
비교할 때는 timingSafeEqual 을 씁니다. 보통의 문자열 비교는 앞에서부터 보다가 다르면 즉시 멈춰서, 몇 글자까지 맞았는지가 걸린 시간에 묻어납니다. 그 차이를 수천 번 재면 한 글자씩 알아낼 수 있습니다. 고정 시간 비교는 늘 끝까지 봅니다.
인증과 인가가 갈리는 지점
여기까지가 인증입니다. "당신이 누구인가"에 답한 것이고, 아직 "무엇을 해도 되는가"는 아무것도 정해지지 않았습니다.
이 둘을 한 덩어리로 보는 것이 두 번째 흔한 오해입니다. 키를 열쇠로 생각하면 그렇게 됩니다. 열쇠는 문에 맞으면 열리고, 열린 다음에는 안에서 무엇을 하든 열쇠와 상관이 없습니다.
키는 열쇠가 아니라 신분증에 가깝습니다. 신분증은 내가 누구인지만 말하고, 그 사람이 어디에 들어갈 수 있는지는 따로 적힌 명단이 정합니다. 게이트웨이에서 그 명단은 키에 붙어 있는 네 개의 값입니다.
| 값 | 정하는 것 | 넘으면 |
|---|---|---|
allowedModels |
이 키가 부를 수 있는 모델 | 요청 거부 |
rateLimitPerMin |
분당 요청 수 | 429 |
dailyTokenLimit |
하루 토큰 상한 | 402 |
monthlyTokenLimit |
테넌트의 월 토큰 상한 | 402 |
모델 목록을 키에 박아 두는 이유는 자원이 공유되기 때문입니다. GPU 를 여러 서비스가 나눠 쓰는 환경에서 큰 모델을 아무나 부르게 두면, 그 요청 하나가 같은 자원을 쓰는 다른 서비스까지 함께 느리게 만듭니다. "돌아가는 모델"과 "열어도 되는 모델"은 다른 목록입니다.
앞의 셋은 키마다 다르고 마지막 하나는 테넌트 단위입니다. 키 다섯 개를 발급해도 회사 전체의 월 상한은 하나라야 말이 되기 때문입니다.
막는 순서가 정책 자체보다 까다로운 이유
정책을 정하는 것보다 어느 순서로 검사할지가 더 어렵습니다.
토큰 상한은 성질이 다릅니다. 요청을 받는 순간에는 이 요청이 몇 토큰을 쓸지 알 수 없습니다. 모델이 답을 다 만들어야 압니다. 그래서 토큰 검사는 "지금까지 쓴 양"으로 판정하는 사후 집계이고, 상한을 넘기는 마지막 한 건은 항상 무료로 통과합니다. 이건 구현을 잘하면 없어지는 문제가 아니라 구조에서 나옵니다.
한 건이 새는 것은 감수할 만합니다. 문제는 그 한 건이 초당 수백 건이 될 때입니다. 그래서 분당 요청 제한을 토큰 검사보다 앞에 둡니다. 폭주는 그쪽에서 잡히고, 뒤에서 새는 것은 실제로 한 건에 머뭅니다.
검사 순서는 키 확인, 폐기·만료 확인, 테넌트 상태, 분당 요청, 하루 토큰, 월 토큰입니다. 앞쪽일수록 싸고 확실한 검사입니다.
한 가지 더 있습니다. 없는 키와 틀린 키에 같은 응답을 돌려줍니다. 둘을 구분해 주면 공격자가 앞자리만 바꿔 가며 어느 prefix 가 살아 있는지 알아낼 수 있습니다. 실패는 실패라고만 말하는 편이 낫습니다.
폐기가 삭제가 아닌 이유
키를 버릴 때 행을 지우지 않고 revokedAt 에 시각을 적습니다.
사용량 원장이 키를 참조하고 있어서입니다. 요청 한 건이 원장 한 행이고, 과금과 쿼터와 장애 추적이 전부 거기서 나옵니다. 키를 지우면 그 키가 만든 과거 기록이 함께 사라지거나 주인을 잃습니다. 유출된 키를 급히 막는 일과 지난달 청구서를 설명하는 일은 둘 다 필요합니다.
원장에는 프롬프트 본문을 넣지 않습니다. 고객 문서가 지나가는 경로라, 본문을 남기는 순간 이 데이터베이스가 개인정보와 계약서 저장소가 됩니다. 남기는 것은 어느 키가 어느 모델을 언제 불렀고 토큰이 얼마였는지까지입니다.
여기까지가 확실한 부분
지금까지는 구현이 갈릴 여지가 별로 없는 이야기입니다. 원문을 저장하지 않는 것, 조회용 앞자리를 따로 두는 것, 고정 시간 비교, 폐기를 플래그로 두는 것은 API 키를 다루는 곳이면 대체로 같은 모양입니다.
갈리는 것은 그 뒤입니다. 상한을 넘겼을 때 402 를 줄지 429 를 줄지는 정한 사람 마음이고, 토큰 상한을 키에 둘지 테넌트에 둘지도 요금제를 어떻게 팔 것인가에 따라 달라집니다. 사후 집계로 한 건이 새는 것을 아예 없애려면 요청 전에 최대 토큰을 예약했다가 정산하는 방식으로 가야 하는데, 그러면 스트리밍 중간에 끊는 처리가 붙습니다. 저는 지금 규모에서는 거기까지 갈 이유가 없다고 보지만, 토큰이 곧 돈인 서비스라면 판단이 달라질 수 있습니다.
키를 안전하게 다루는 일과 키에 권한을 붙이는 일은 사실 별개의 문제입니다. 앞은 정답이 거의 정해져 있고, 뒤는 무엇을 팔 것인지가 정합니다.
함께 읽기
- 전자서명의 효력과 증거력: 유효한 것과 증명되는 것의 차이전자서명 도입을 검토하면 거의 항상 같은 질문이 먼저 나옵니다. "이거 법적으로 효력 있나요?"
- 전자서명 구현: 손글씨보다 문서 동결이 먼저인 이유전자서명 서비스를 처음 보면 가장 눈에 띄는 것은 화면에 손가락이나 마우스로 사인을 그리는 부분입니다. 계약이 그 그림으로 성립하는 것처럼 보입니다.
- 개인 액세스 토큰의 한계: 토큰은 권한이 아니라 신원- run: eas build --platform all --non-interactive
- Google Analytics 도입: 코드보다 처리방침에서 먼저 막힌 이유Google Analytics를 붙이는 코드를 다 짜고, 배포 직전에 개인정보 처리방침을 열었습니다. 이 문장이 있었습니다.
- Next.js 보안 헤더와 색인 차단: 새 프로젝트마다 같은 설정을 두는 이유Vercel 로 옮기고 나서 응답 헤더를 다시 봤더니 보안 헤더가 하나도 없었습니다. 예전에는 오리진 앞의 nginx 가 붙여 주고 있었는데, 그 nginx 가 경로에서 빠지면서 전 경로에서 같이 사라진 것입니다. 앱은 그대로였고 오류도 없었습니다. 헤더는 없어져도 화면이 멀쩡해서, 재보기 전에는 모릅니다.