About

API 키는 왜 한 번만 보여 줄까 — 「다시 보기」 버튼을 안 만든 이유

DB 에는 SHA256 다이제스트와 앞 12자 라벨만 남긴다. 시스템도 원래 키를 모른다

Rails 로 API 키 발급을 만들 때 「한 번만 보여 주기」 를 어떻게, 왜 하는지 정리했다. 다 읽으면 키를 DB 에 어떤 모양으로 넣을지, 그리고 「다시 보고 싶어요」 라는 요청에 뭐라고 답할지 정할 수 있다.

결론부터 말하면 다시 못 본다. 잊었으면 실효하고 새로 발급한다. 시스템도 그 키를 모르게 만들어 뒀기 때문이다.

리뷰에서 받은 질문

외부에 공개하는 API 를 만드는 프로젝트에서 API 키 발급을 붙였다. 리뷰에서 이런 질문이 나왔다. 「목록에는 키 앞부분만 보이는데, 나중에 다시 복사하고 싶으면 어떻게 해요?」

처음엔 복사 버튼 하나 다는 UX 얘기로 들렸다. 그런데 답을 설명하다 보니 설계 판단이 줄줄이 따라 나왔다.

DB 에 남기는 건 두 개뿐

평문 키는 어디에도 저장하지 않는다. 남기는 건 키의 SHA256 해시인 token_digest 와, 앞 12자인 token_prefix 다. 인증할 때는 들어온 키를 다시 해시해서 token_digest 로 찾는다. prefix 는 화면에서 「어느 키인지」 구분하는 라벨일 뿐 인증에는 안 쓴다.

Issue: the raw key lives for one response onlyApiKey.issue!SecureRandom.hex(24)Raw key, 52 chars. Shown to the user once.abc_1a2b3c4d9f0e... (40 more chars)saved asapi_keys tabletoken_digest = SHA256(raw key)token_prefix = abc_1a2b3c4d (label)raw key: not stored anywhereAuth: hash again, look upAuthorization:Bearer abc_1a2b...find_by(token_digest: SHA256(given_key))The prefix on the list screen is 12 of 52 chars. Copying it gets you nothing.

키는 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 나 서비스 계정 쪽을 먼저 본다.

핵심 포인트

1

발급할 때 SecureRandom.hex(24) 로 192비트 키를 만들고 서비스 접두사(abc_)를 붙인다

2

DB 에는 SHA256 다이제스트와 앞 12자 라벨만 넣는다. 평문은 응답 한 번에만 담는다

3

인증은 들어온 키를 다시 해시해서 find_by(token_digest:) 로 찾는다

4

다시 보기 대신 이름 · 최종 이용 일시 · 실효 버튼을 준다

5

발급 상한은 with_lock, last_used_at 은 1분 스로틀, 실효는 revoked_at 논리 삭제

사용 사례

외부 공개 API 에 사용자별 키 발급 붙이기 「키를 다시 보여 달라」 는 요청에 설계 근거로 답하기
← 한 Rails 앱에 서비스 여러 개 — catch-all 이 옆 서비스 요청을 먹는 이유 로그인 없는 「본인 모드」 — 비밀 URL 인증은 1:1 이 아니라 1:N 이다 →