<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>API Key on jyukki's Blog</title><link>https://jyukki.com/tags/api-key/</link><description>Recent content in API Key on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/api-key/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: API Key Lifecycle 발급·회전·폐기 플레이북</title><link>https://jyukki.com/learning/deep-dive/deep-dive-api-key-lifecycle-rotation-revocation-playbook/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-api-key-lifecycle-rotation-revocation-playbook/</guid><description>API Key를 단순 문자열 토큰이 아니라 발급, 저장, 권한, 사용량 제한, 회전, 폐기, 감사 로그까지 이어지는 운영 자산으로 설계하는 기준을 정리합니다.</description><content:encoded><![CDATA[<p>API Key는 구현이 쉬워 보입니다. 관리자 화면에서 긴 랜덤 문자열을 하나 만들고, 클라이언트가 <code>Authorization</code> 헤더나 <code>X-API-Key</code> 헤더에 실어 보내면 됩니다. 그래서 많은 서비스가 내부 도구, 파트너 연동, 배치 작업, 서버 간 호출을 빠르게 열 때 API Key부터 붙입니다. 문제는 그 다음입니다. 누가 만든 키인지, 어떤 권한을 가졌는지, 마지막으로 언제 쓰였는지, 유출됐을 때 몇 분 안에 막을 수 있는지, 회전 중에 어떤 클라이언트가 깨지는지 답하지 못하면 API Key는 인증 수단이 아니라 장기 장애 씨앗이 됩니다.</p>
<p>API Key 설계의 핵심은 &ldquo;키를 안전하게 생성한다&quot;에서 끝나지 않습니다. 실무에서는 키가 만들어진 뒤의 전체 수명주기가 더 중요합니다. 발급 이유, scope, owner, 만료일, rate limit, 마지막 사용 시각, 폐기 상태, 감사 로그가 같이 있어야 운영자가 판단할 수 있습니다. 이 글은 <a href="/learning/deep-dive/deep-dive-secret-management/">시크릿 관리</a>, <a href="/learning/deep-dive/deep-dive-authorization-models-rbac-abac-rebac/">인증/인가 모델</a>, <a href="/learning/deep-dive/deep-dive-tamper-evident-audit-log-playbook/">Tamper-Evident Audit Log</a>, <a href="/learning/deep-dive/deep-dive-api-rate-limit-backpressure/">API Rate Limit과 Backpressure</a>와 이어지는 관점으로 API Key를 운영 가능한 자산으로 다루는 기준을 정리합니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>API Key를 비밀번호처럼 저장하면 왜 위험한지, 해시 저장과 prefix lookup을 어떻게 나눠야 하는지 이해합니다.</li>
<li>발급, scope, 만료, 회전, 폐기, 감사 로그를 하나의 lifecycle로 묶는 기준을 잡을 수 있습니다.</li>
<li>파트너 연동이나 내부 서버 간 호출에서 키 유출 피해를 줄이는 rate limit, allowlist, owner 정책을 설계할 수 있습니다.</li>
<li>&ldquo;키를 새로 발급했다&quot;가 아니라 &ldquo;유출돼도 15분 안에 막고, 7일 안에 회전할 수 있다&quot;는 운영 목표를 세울 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-api-key는-identity와-permission을-동시에-표현하면-망가지기-쉽다">1) API Key는 identity와 permission을 동시에 표현하면 망가지기 쉽다</h3>
<p>API Key를 처음 만들 때 흔한 실수는 키 문자열 하나에 너무 많은 의미를 기대하는 것입니다. &ldquo;이 키를 가진 클라이언트는 A 회사이고, 주문 조회도 가능하고, 정산 다운로드도 가능하고, 운영 중단 시 우회도 가능하다&quot;처럼 쓰면 나중에 분리하기가 어렵습니다.</p>
<p>실무에서는 최소한 아래 개념을 분리합니다.</p>
<table>
  <thead>
      <tr>
          <th>개념</th>
          <th>예시</th>
          <th>저장 위치</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Key material</td>
          <td>실제 비밀 문자열</td>
          <td>최초 발급 시 1회 노출, 서버에는 해시만 저장</td>
      </tr>
      <tr>
          <td>Key id</td>
          <td><code>ak_live_9f3a...</code> 같은 식별자</td>
          <td>DB, 로그, 감사 이벤트</td>
      </tr>
      <tr>
          <td>Principal</td>
          <td>파트너사, 내부 서비스, 자동화 계정</td>
          <td>계정/조직/서비스 테이블</td>
      </tr>
      <tr>
          <td>Scope</td>
          <td><code>orders:read</code>, <code>settlements:write</code></td>
          <td>권한 정책</td>
      </tr>
      <tr>
          <td>Policy</td>
          <td>rate limit, IP allowlist, 만료, 환경</td>
          <td>key policy 테이블</td>
      </tr>
  </tbody>
</table>
<p>키 자체가 권한 모델이 되면, 권한 변경 때마다 키를 새로 뿌려야 합니다. 반대로 key id와 principal, scope를 분리하면 &ldquo;같은 파트너의 읽기 권한만 줄이기&rdquo;, &ldquo;정산 scope만 24시간 중지&rdquo;, &ldquo;staging 키만 폐기&rdquo; 같은 운영이 가능합니다.</p>
<p>판단 기준은 간단합니다. 키 하나가 서로 다른 위험도의 작업을 3개 이상 수행한다면 scope를 쪼개는 편이 안전합니다. 특히 읽기, 쓰기, 관리자 작업, 외부 전송은 같은 키에 묶지 않는 것을 기본값으로 둡니다.</p>
<h3 id="2-서버에는-원문-키를-저장하지-않는다">2) 서버에는 원문 키를 저장하지 않는다</h3>
<p>API Key는 사용자가 다시 볼 수 없어도 됩니다. 오히려 다시 볼 수 있으면 위험합니다. 서버 DB에 원문 키를 저장하면 DB read 권한 하나가 모든 파트너 권한으로 바뀝니다. 따라서 원칙은 비밀번호와 비슷합니다.</p>
<ul>
<li>발급 시 원문 키는 1회만 보여준다.</li>
<li>서버에는 <code>key_hash</code>만 저장한다.</li>
<li>조회 성능을 위해 앞부분 prefix를 별도로 저장한다.</li>
<li>로그에는 원문이 아니라 <code>key_id</code>와 prefix 일부만 남긴다.</li>
</ul>
<p>예를 들어 키 형식을 <code>ak_live_&lt;public_prefix&gt;_&lt;secret_random&gt;</code>로 두면, 서버는 public prefix로 후보를 좁힌 뒤 secret 전체를 HMAC/SHA-256 계열로 비교할 수 있습니다. 여기서 단순 SHA-256보다 서버 측 pepper를 섞은 HMAC이 낫습니다. DB가 유출돼도 오프라인 대입 비용을 높일 수 있기 때문입니다.</p>
<p>권장 기준:</p>
<ul>
<li>랜덤 secret: 최소 128bit 이상, 가능하면 192~256bit</li>
<li>prefix: 운영자가 식별 가능한 8~12자 수준</li>
<li>원문 키 재조회: 금지</li>
<li>키 발급 화면 재노출: 금지, 복사 후 닫으면 끝</li>
<li>로그/trace/body 저장: 원문 키 자동 마스킹 테스트 필수</li>
</ul>
<p>이 기준은 <a href="/learning/deep-dive/deep-dive-structured-logging/">구조화 로깅</a>의 민감정보 마스킹 원칙과 같습니다. &ldquo;개발자가 조심한다&quot;가 아니라, 테스트와 필터가 원문 노출을 막아야 합니다.</p>
<h3 id="3-만료와-회전은-선택-기능이-아니라-운영-안전장치다">3) 만료와 회전은 선택 기능이 아니라 운영 안전장치다</h3>
<p>API Key가 한 번 발급된 뒤 2년 동안 살아 있다면 언젠가 유출된다고 보는 편이 현실적입니다. 파트너사의 CI 로그, 노트북, 공유 문서, Postman collection, 외주 개발 환경까지 키가 지나가는 경로는 생각보다 많습니다. 그래서 키는 처음부터 회전 가능해야 합니다.</p>
<p>회전 모델은 보통 두 가지입니다.</p>
<ol>
<li><strong>Dual key window</strong>
<ul>
<li>새 키와 이전 키를 일정 기간 함께 허용합니다.</li>
<li>클라이언트가 새 키로 전환한 뒤 이전 키를 폐기합니다.</li>
</ul>
</li>
<li><strong>Versioned key</strong>
<ul>
<li>같은 logical credential에 여러 version을 둡니다.</li>
<li>현재 활성 version과 이전 version의 사용량을 같이 봅니다.</li>
</ul>
</li>
</ol>
<p>권장 숫자 기준:</p>
<ul>
<li>일반 파트너 키 만료: 90~180일</li>
<li>내부 자동화 키 만료: 30~90일</li>
<li>high-risk scope 키 만료: 7~30일</li>
<li>dual key window: 7~14일</li>
<li>이전 키 사용량 0건이 24~48시간 유지되면 폐기</li>
<li>유출 의심 시 폐기 목표: 15분 이내</li>
</ul>
<p>만료가 짧을수록 안전하지만 운영 비용도 늘어납니다. 그래서 모든 키를 7일로 줄이는 것보다 scope와 위험도별로 나누는 편이 낫습니다. 결제, 개인정보, 권한 변경, 관리자 API는 짧게 가져가고, 읽기 전용 저위험 키는 더 긴 만료를 둘 수 있습니다.</p>
<h3 id="4-폐기는-삭제가-아니라-상태-전이다">4) 폐기는 삭제가 아니라 상태 전이다</h3>
<p>키를 폐기할 때 DB row를 바로 지우면 사고 분석이 어려워집니다. 언제, 누가, 왜 폐기했는지와 폐기 후에도 요청이 들어오는지 봐야 합니다. 그래서 키 상태는 최소 아래처럼 둡니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ACTIVE -&gt; ROTATING -&gt; DISABLED -&gt; REVOKED -&gt; ARCHIVED
</span></span></code></pre></div><ul>
<li><code>ACTIVE</code>: 정상 사용</li>
<li><code>ROTATING</code>: 새 키가 발급됐고 이전 키는 제한된 기간 허용</li>
<li><code>DISABLED</code>: 임시 중지, 복구 가능</li>
<li><code>REVOKED</code>: 폐기 확정, 복구 불가</li>
<li><code>ARCHIVED</code>: 보존 기간 이후 조회 전용</li>
</ul>
<p>폐기 직후 404처럼 보이게 할지, 401/403으로 명확히 돌려줄지도 정해야 합니다. 외부 파트너 API라면 <code>401 invalid_api_key</code>가 운영에 도움이 됩니다. 공격 표면을 줄이는 API라면 자세한 이유를 숨기는 편이 낫습니다. 핵심은 상태 전이가 감사 로그로 남고, 폐기된 키의 재사용 시도가 별도 지표로 보이는 것입니다.</p>
<h3 id="5-api-key는-rate-limit과-감사-로그-없이-운영하면-안-된다">5) API Key는 rate limit과 감사 로그 없이 운영하면 안 된다</h3>
<p>API Key는 인증 수단이면서 과금, abuse 방지, 파트너 SLA의 기준이 됩니다. 따라서 모든 키에는 기본 rate limit과 사용량 지표가 붙어야 합니다.</p>
<p>최소 지표:</p>
<ul>
<li>key별 요청 수, 에러율, p95 지연</li>
<li>endpoint별 호출 분포</li>
<li>마지막 사용 시각</li>
<li>허용되지 않은 scope 접근 횟수</li>
<li>IP/ASN 변화</li>
<li>revoked key 재사용 시도</li>
</ul>
<p>실무 임계치 예시:</p>
<ul>
<li>평시 대비 key별 QPS 5배 이상 상승: abuse 후보</li>
<li>실패율 20% 이상이 10분 지속: 통합 오류 또는 공격 후보</li>
<li>미사용 30일 초과 key: 회수 후보</li>
<li>revoked key 사용 1회 이상: 보안 알림</li>
<li>신규 IP/region에서 high-risk scope 호출: step-up 또는 일시 제한</li>
</ul>
<p>이 지표가 없으면 키 유출과 정상 트래픽 증가를 구분하기 어렵습니다. <a href="/learning/deep-dive/deep-dive-api-rate-limit-backpressure/">API Rate Limit과 Backpressure</a>를 적용할 때도 사용자 단위만 보지 말고 key id와 principal 단위를 같이 봐야 합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-데이터-모델-초안">1) 데이터 모델 초안</h3>
<p>API Key 테이블은 단순히 <code>id</code>, <code>key</code>, <code>created_at</code>으로 끝내면 안 됩니다. 최소 아래 필드를 권장합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>api_keys
</span></span><span style="display:flex;"><span>- id
</span></span><span style="display:flex;"><span>- public_prefix
</span></span><span style="display:flex;"><span>- key_hash
</span></span><span style="display:flex;"><span>- principal_type
</span></span><span style="display:flex;"><span>- principal_id
</span></span><span style="display:flex;"><span>- environment
</span></span><span style="display:flex;"><span>- status
</span></span><span style="display:flex;"><span>- scopes
</span></span><span style="display:flex;"><span>- rate_limit_policy_id
</span></span><span style="display:flex;"><span>- ip_allowlist_policy_id
</span></span><span style="display:flex;"><span>- created_by
</span></span><span style="display:flex;"><span>- created_reason
</span></span><span style="display:flex;"><span>- expires_at
</span></span><span style="display:flex;"><span>- last_used_at
</span></span><span style="display:flex;"><span>- rotating_from_key_id
</span></span><span style="display:flex;"><span>- revoked_at
</span></span><span style="display:flex;"><span>- revoked_by
</span></span><span style="display:flex;"><span>- revoke_reason
</span></span></code></pre></div><p>원문 키는 없습니다. <code>created_reason</code>은 생각보다 중요합니다. &ldquo;파트너 A 정산 자동화&rdquo;, &ldquo;사내 배치 job-x&rdquo;, &ldquo;마이그레이션 임시 접근&quot;처럼 이유를 남기면 90일 뒤 회수 판단이 쉬워집니다. 이유 없는 키는 대부분 고아 키가 됩니다.</p>
<h3 id="2-발급-플로우">2) 발급 플로우</h3>
<p>발급은 관리자 화면 버튼 하나보다 작은 승인 플로우로 다루는 편이 안전합니다.</p>
<ol>
<li>요청자가 principal과 scope를 선택한다.</li>
<li>시스템이 위험도를 계산한다.</li>
<li>high-risk scope면 owner 승인 또는 보안 리뷰를 요구한다.</li>
<li>키를 생성하고 원문을 1회만 보여준다.</li>
<li>감사 로그에 key id, scope, owner, 만료일을 남긴다.</li>
<li>첫 24시간 사용량을 관찰한다.</li>
</ol>
<p>위험도 기준 예시:</p>
<table>
  <thead>
      <tr>
          <th>위험도</th>
          <th>조건</th>
          <th>승인</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Low</td>
          <td>읽기 전용, public-ish 데이터, 만료 90일 이하</td>
          <td>팀 owner</td>
      </tr>
      <tr>
          <td>Medium</td>
          <td>고객 데이터 조회, partner integration, rate limit 상향</td>
          <td>서비스 owner</td>
      </tr>
      <tr>
          <td>High</td>
          <td>쓰기, 삭제, 권한 변경, 정산/결제, 만료 30일 초과</td>
          <td>보안/플랫폼 승인</td>
      </tr>
  </tbody>
</table>
<p>high-risk 키는 발급보다 회수가 더 중요합니다. 발급 시점에 폐기 owner와 만료일이 없으면 승인을 막는 편이 낫습니다.</p>
<h3 id="3-인증-미들웨어-처리-순서">3) 인증 미들웨어 처리 순서</h3>
<p>요청 처리 순서는 아래처럼 고정합니다.</p>
<ol>
<li>헤더에서 키 추출</li>
<li>형식 검증과 prefix 조회</li>
<li>HMAC 비교</li>
<li>status와 만료 확인</li>
<li>principal 상태 확인</li>
<li>scope 확인</li>
<li>rate limit과 IP allowlist 확인</li>
<li>감사/사용량 이벤트 기록</li>
<li>handler 실행</li>
</ol>
<p>여기서 8번을 handler 뒤로만 미루면 실패한 인증 시도나 scope 위반이 빠질 수 있습니다. 최소한 인증 성공/실패와 거부 이유는 구조화 이벤트로 남겨야 합니다. 단, 원문 키와 민감 payload는 절대 남기지 않습니다.</p>
<h3 id="4-회전-런북">4) 회전 런북</h3>
<p>회전은 평시에 연습해야 합니다. 사고 때 처음 하면 파트너 커뮤니케이션과 배포가 같이 꼬입니다.</p>
<p>권장 런북:</p>
<ol>
<li>새 키 발급, 이전 키는 <code>ROTATING</code></li>
<li>파트너/서비스 owner에게 만료일 공지</li>
<li>이전 키와 새 키의 사용량을 1시간 단위로 비교</li>
<li>이전 키 사용량이 24~48시간 0건이면 <code>DISABLED</code></li>
<li>7일 뒤 <code>REVOKED</code></li>
<li>30~90일 뒤 archive 또는 보존 정책 적용</li>
</ol>
<p>회전 중 에러율이 1%p 이상 오르거나, 이전 키 사용량이 마감 24시간 전에도 10% 이상 남아 있으면 폐기 일정을 미룹니다. 단, 유출 의심 회전은 예외입니다. 이때는 호환성보다 피해 차단이 먼저라서 즉시 <code>REVOKED</code>하고 대체 키를 별도 채널로 전달합니다.</p>
<h3 id="5-운영-대시보드">5) 운영 대시보드</h3>
<p>대시보드는 멋진 그래프보다 회수 판단을 빠르게 만들어야 합니다.</p>
<ul>
<li>만료 14일 이내 key</li>
<li>30일 이상 미사용 key</li>
<li>owner 없는 key</li>
<li>high-risk scope인데 IP allowlist 없는 key</li>
<li>revoked key 재사용 시도</li>
<li>key별 QPS 상위 20개</li>
<li>key별 실패율 상위 20개</li>
<li>scope 위반 상위 20개</li>
</ul>
<p>주간 운영 기준은 &ldquo;미사용 key 회수율&quot;을 봅니다. 매주 미사용 30일 초과 키의 80% 이상이 owner 확인 또는 폐기 상태로 이동하면 관리가 되고 있는 것입니다. 반대로 key 수만 늘고 회수율이 없으면 API Key는 곧 shadow access가 됩니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, 너무 짧은 만료는 통합 파트너를 피곤하게 만듭니다. 보안만 보고 모든 키를 7일 만료로 만들면 운영팀은 매주 장애 대응을 하게 됩니다. 위험도별 만료와 자동 알림이 현실적인 균형입니다.</p>
<p>둘째, IP allowlist는 만능이 아닙니다. 클라우드 egress IP가 자주 바뀌거나 파트너가 NAT 뒤에 있으면 allowlist 운영이 병목이 됩니다. 하지만 high-risk scope에는 IP, mTLS, 서명 요청, step-up 승인 중 최소 하나의 추가 경계를 두는 편이 안전합니다.</p>
<p>셋째, 키 prefix를 너무 길게 노출하면 식별에는 편하지만 공격자에게 단서가 됩니다. prefix는 운영 식별용이지 인증 요소가 아닙니다. secret 부분의 엔트로피와 해시 비교가 실제 방어선입니다.</p>
<p>넷째, 폐기된 키 row를 바로 삭제하면 사고 분석이 어려워집니다. 개인정보가 아니라 credential metadata라면 보존 기간을 두고 감사 가능성을 확보하는 편이 좋습니다. 다만 owner, IP, 사용량 이벤트가 개인정보와 결합될 수 있으므로 보존 기간과 접근 권한을 명확히 해야 합니다.</p>
<p>다섯째, API Key는 OAuth를 완전히 대체하지 않습니다. 사용자가 직접 권한을 위임하고 철회해야 하는 3rd-party 앱 생태계라면 <a href="/learning/deep-dive/deep-dive-oauth2-oidc/">OAuth2/OIDC</a>가 맞습니다. API Key는 서버 간 통합과 자동화에 적합하지만, 사용자별 consent와 세밀한 delegated access에는 약합니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 원문 API Key를 DB에 저장하지 않고, 해시와 prefix만 저장한다.</li>
<li><input disabled="" type="checkbox"> 모든 key에 owner, principal, scope, environment, 만료일이 있다.</li>
<li><input disabled="" type="checkbox"> read/write/admin/external-send scope가 분리되어 있다.</li>
<li><input disabled="" type="checkbox"> high-risk key는 7~30일 만료 또는 추가 승인 경계를 가진다.</li>
<li><input disabled="" type="checkbox"> 키 회전 시 dual key window와 이전 키 폐기 조건이 숫자로 정해져 있다.</li>
<li><input disabled="" type="checkbox"> revoked key 재사용 시도가 알림으로 올라온다.</li>
<li><input disabled="" type="checkbox"> 30일 이상 미사용 key 회수 프로세스가 있다.</li>
<li><input disabled="" type="checkbox"> 감사 로그에는 key id와 판단 근거만 남고 원문 key는 남지 않는다.</li>
</ul>
<h3 id="연습">연습</h3>
<ol>
<li>현재 서비스의 API Key 또는 내부 토큰 10개를 뽑아 owner, scope, 만료일, 마지막 사용 시각을 표로 정리해 보세요. owner가 없거나 30일 이상 미사용이면 회수 후보입니다.</li>
<li><code>orders:read</code>, <code>orders:write</code>, <code>settlements:download</code>, <code>admin:user:delete</code> 네 scope를 기준으로 발급 승인 등급을 나눠 보세요. 각 scope의 만료일과 rate limit도 숫자로 적습니다.</li>
<li>&ldquo;파트너 키 유출 의심&rdquo; 상황을 가정하고 15분 안에 할 일을 런북으로 작성해 보세요. 즉시 폐기, 대체 키 발급, 파트너 공지, revoked key 재사용 모니터링, 포스트모템 항목이 들어가야 합니다.</li>
</ol>
<h2 id="함께-보면-좋은-글">함께 보면 좋은 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-secret-management/">시크릿 관리 실무</a></li>
<li><a href="/learning/deep-dive/deep-dive-authorization-models-rbac-abac-rebac/">인증/인가 모델: RBAC·ABAC·ReBAC</a></li>
<li><a href="/learning/deep-dive/deep-dive-tamper-evident-audit-log-playbook/">Tamper-Evident Audit Log 플레이북</a></li>
<li><a href="/learning/deep-dive/deep-dive-api-rate-limit-backpressure/">API Rate Limit과 Backpressure</a></li>
<li><a href="/learning/deep-dive/deep-dive-oauth2-oidc/">OAuth2/OIDC 심화</a></li>
</ul>
]]></content:encoded></item></channel></rss>