API 키는 왜 한 번만 보여 줄까 — 「다시 보기」 버튼을 안 만든 이유
DB 에는 SHA256 다이제스트와 앞 12자 라벨만 남긴다. 시스템도 원래 키를 모른다
Rails 로 API 키 발급을 만들 때 「한 번만 보여 주기」 를 어떻게, 왜 하는지 정리했다. 다 읽으면 키를 DB 에 어떤 모양으로 넣을지, 그리고 「다시 보고 싶어요」 라는 요청에 뭐라고 답할지 정할 수 있다.
결론부터 말하면 다시 못 본다. 잊었으면 실효하고 새로 발급한다. 시스템도 그 키를 모르게 만들어 뒀기 때문이다.
리뷰에서 받은 질문
외부에 공개하는 API 를 만드는 프로젝트에서 API 키 발급을 붙였다. 리뷰에서 이런 질문이 나왔다. 「목록에는 키 앞부분만 보이는데, 나중에 다시 복사하고 싶으면 어떻게 해요?」
처음엔 복사 버튼 하나 다는 UX 얘기로 들렸다. 그런데 답을 설명하다 보니 설계 판단이 줄줄이 따라 나왔다.
DB 에 남기는 건 두 개뿐
평문 키는 어디에도 저장하지 않는다. 남기는 건 키의 SHA256 해시인 token_digest 와, 앞 12자인 token_prefix 다. 인증할 때는 들어온 키를 다시 해시해서 token_digest 로 찾는다. prefix 는 화면에서 「어느 키인지」 구분하는 라벨일 뿐 인증에는 안 쓴다.
키는 abc_ + 16진수 48자, 총 52자다. 목록에 보이는 abc_1a2b3c4d 는 앞 12자 조각이고 나머지 40자는 어디에도 없다. 그래서 prefix 를 복사해 봐야 못 쓴다. 리뷰에서 나온 오해의 절반은 이걸로 풀렸다.
암호화가 아니라 해시인 이유
「암호화해서 넣고 보여 줄 때 복호화하면 되잖아」 라는 말이 꼭 나온다. 암호화는 되돌릴 수 있다. 앱이 복호화 키를 들고 있다는 뜻이다. 해시는 대조만 된다. 비밀번호를 알려 주지 않고 재설정만 시키는 서비스와 같은 이유다.
다만 비밀번호처럼 bcrypt 를 쓰진 않는다. 비밀번호는 사람이 만들어서 맞히기 쉽고, 그래서 일부러 느린 해시를 쓴다. 이 키는 SecureRandom.hex(24), 192비트 랜덤이라 무차별 대입이 애초에 안 된다. 요청마다 인증하니까 빠른 SHA256 이 맞다. bcrypt 는 솔트 때문에 같은 키도 매번 다른 값이 나와서 find_by(token_digest:) 한 번으로 못 찾는다.
다시 보기 버튼의 값
버튼을 만들려면 52자 전체를 되돌릴 수 있는 형태로 DB 에 둬야 한다. 그 순간 지금은 아무 일 없는 경로가 전부 유출 경로가 된다.
DB 백업이나 스냅샷, 운영 데이터를 개발 환경에 복사하는 작업이 먼저 걸린다. 복사본 하나하나가 살아 있는 키 목록이 된다. 운영 콘솔 권한이 있는 사람은 남의 키를 읽을 수 있다. 감사 로그는 키 주인 이름으로 남으니 누가 했는지도 흐려진다.
로그 · 에러 리포팅 · CSV 내보내기처럼 레코드가 밖으로 나가는 코드는 생각보다 많다. 지금은 해시가 섞여 나가도 그만이지만, 평문이면 하나만 실수해도 유출이다. 암호화로 바꿔도 DB 와 앱 설정을 같이 볼 수 있는 사람 · 서버 · CI 앞에서는 결국 풀린다.
얻는 편의는 「잊었을 때 다시 본다」 하나다. 대신하는 비용은 실효하고, 재발급하고, 설정에 새 키를 붙여 넣는 한 번. 계산이 안 맞는다. GitHub 의 personal access token 이나 AWS 의 secret access key 도 만들 때 한 번만 보여 준다.
대신 줘야 하는 것
「못 본다」 로 끝내면 불친절하다. 그래서 키에 이름을 붙이게 하고, 최종 이용 일시를 보여 주고, 실효 버튼을 두고, 발급 직후 화면에만 복사 버튼을 크게 뒀다. 최종 이용 일시가 보이면 안 쓰는 키를 마음 놓고 끌 수 있다.
만들면서 밟은 것
활성 키 10개 제한을 validate 로 세면 동시 요청에 뚫린다. 검사와 생성 사이에 다른 요청이 끼어들기 때문이다. 사용자 행을 with_lock 으로 잠가 같은 사람의 발급을 줄 세웠다.
last_used_at 은 요청마다 UPDATE 라서 바쁜 키 하나에 쓰기가 몰린다. 1분 안에 갱신했으면 건너뛰고 update_column 으로 콜백을 피했다.
제일 아팠던 건 권한 스코프다. read / read_write 컬럼과 쓰기 게이트를 만들어 놓고, 발급 화면에서 고르는 UI 를 걷어 낸 적이 있다. 그러자 모든 키가 최고 권한으로 나갔고, 사양서의 「두 종류 중 선택」 과 구현이 어긋났다. 사용자가 고를 수 있어야 기능이다.
코드와 저장 방식 비교표는 아래 상세에 있다. API 키나 권한처럼 사고가 나야 티가 나는 부분을 설계하고 있다면, negabaro 에 같이 봐 달라고 해도 된다.
키를 어떤 모양으로 저장하나
| 저장 방식 | 다시 보기 | 한 번에 찾기 | DB 가 새면 |
|---|---|---|---|
| 평문 | 된다 | 된다 | 전원의 키가 그대로 샌다 |
| 암호화 | 된다 | 결정적 암호화일 때만 | 복호화 키까지 새면 같다 |
| bcrypt | 안 된다 | 안 된다 (솔트) | 안전. 대신 요청마다 느리다 |
| SHA256 (이 글) | 안 된다 | 된다 | 192비트 랜덤이라 되돌릴 수 없다 |
사람이 만든 비밀번호라면 bcrypt 가 맞다. 기계가 만든 긴 랜덤 키라면 SHA256 이 맞다.
발급 · 인증 · 실효
TOKEN_PREFIX = "abc_"
PREFIX_DISPLAY_LENGTH = 12
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
def self.issue!(user:, name:, scopes:)
raw_token = TOKEN_PREFIX + SecureRandom.hex(24)
api_key = user.with_lock do
create!(user:, name:, scopes:,
token_digest: digest(raw_token),
token_prefix: raw_token[0, PREFIX_DISPLAY_LENGTH])
end
[api_key, raw_token] # 평문은 반환값으로만 나간다
end
def self.digest(raw_token) = Digest::SHA256.hexdigest(raw_token)
def self.authenticate(raw_token)
active.find_by(token_digest: digest(raw_token))
end
def touch_last_used!
return if last_used_at.present? && last_used_at > 1.minute.ago
update_column(:last_used_at, Time.current)
end
API 키냐 OAuth 냐
사용자 단위 API 키는 「그 사람 권한 그대로」 라 설계가 단순하다. 대신 그 사람이 퇴사하거나 부서를 옮기면 연동이 통째로 멈춘다. 연동이 조직의 것이어야 한다면 OAuth 나 서비스 계정 쪽을 먼저 본다.
핵심 포인트
발급할 때 SecureRandom.hex(24) 로 192비트 키를 만들고 서비스 접두사(abc_)를 붙인다
DB 에는 SHA256 다이제스트와 앞 12자 라벨만 넣는다. 평문은 응답 한 번에만 담는다
인증은 들어온 키를 다시 해시해서 find_by(token_digest:) 로 찾는다
다시 보기 대신 이름 · 최종 이용 일시 · 실효 버튼을 준다
발급 상한은 with_lock, last_used_at 은 1분 스로틀, 실효는 revoked_at 논리 삭제