~10 min · auth-security, api-keys, tokens, rotation
Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"API 키와 토큰은 HTTP 요청에서 같은 모양으로 보일 수 있어. 진짜 차이는 이름이 아니라 누가 발급하고, 어떤 권한을 주며, 언제 만료·폐기되는지에 있어. 그 수명 주기가 저장 방식과 교체 정책, 사고 대응 범위를 결정해."
같은 전송 형태, 다른 수명 주기
API 키와 단기 토큰은 모두 Authorization: Bearer <string> 형태로 보낼 수 있어. 다만 API 키가 별도 헤더나 Basic 인증을 쓰는 경우도 있으므로 전송 형태만 보고 종류를 단정하면 안 돼. 보통의 차이는 이래:
API 키: 관리 화면이나 관리 API에서 발급하고, 통합이나 조직을 식별하는 장기 자격 증명으로 쓰는 경우가 많아. 만료 시각이 없거나 수동 폐기 전까지 유효한 제품도 흔해.
단기 토큰: 로그인이나 OAuth 흐름, 토큰 교환 과정에서 프로그램으로 발급하고, 짧은 시간 뒤 만료되도록 설계해. 사용자 세션이나 위임된 접근에 잘 맞아.
하지만 이름만으로 수명과 안전성이 정해지는 건 아니야. 만료되는 API 키도 있고 오래가는 토큰도 있어. 실제 속성을 확인해야 해.
누출 가능 시간을 계산해
판단은 대개 이 질문으로 모여: 이 자격 증명이 노출되면 어떤 권한이 얼마나 오래 악용될까?
수명이 긴 API 키가 노출되면: Git 커밋, 프런트엔드 번들, 공유한 스크린샷, CI 로그 같은 곳에서 새어 나간 뒤 발견해 폐기할 때까지 계속 악용될 수 있어. 권한 범위가 넓고 사용량 제한도 약하면 피해가 더 커져.
단기 액세스 토큰이 노출되면: 남은 유효 시간 동안은 똑같이 위험하지만 만료 뒤에는 사용할 수 없어. 다만 갱신 토큰까지 함께 노출되거나 자동 갱신이 가능한 구조라면 피해가 단기 액세스 토큰의 TTL로 끝나지 않아.
그래서 외부 AI API나 결제 API의 공급자 키를 브라우저에 직접 넣으면 안 돼. 통제된 백엔드가 키를 보관하고 최종 사용자 요청을 중개하거나, 공급자가 지원한다면 용도와 수명이 제한된 임시 자격 증명을 발급해야 해.
API 키가 알맞은 경우
API 키는 단순하고 통합하기 쉬워. 다음 조건을 갖춘 서버 간 호출에 잘 맞아:
통제된 서버 환경 — 키를 비밀값 관리자나 보호된 런타임 설정에 보관하고 브라우저로 보내지 않아. 서버에도 로그, 오류 보고, 백업 같은 누출 경로가 있으므로 접근 통제와 가림 처리는 여전히 필요해.
통합 단위의 신원 — 키가 개인 사용자보다 조직이나 서비스, 특정 통합을 나타내.
안전한 교체 절차 — 새 키와 기존 키를 잠시 함께 운영해 중단 없이 교체할 수 있어.
안정적인 권한 범위 — 호출 주체와 필요한 권한이 자주 바뀌지 않아.
Stripe의 비밀 API 키 sk_live_...와 Anthropic의 ANTHROPIC_API_KEY는 대표적인 서버용 자격 증명이야. Twilio 인증 토큰과 GitHub 개인 접근 토큰은 이름은 토큰이지만, 장기 저장해 자동화에 쓰면 운영상 비슷한 보호·교체 문제가 생겨. 결국 제품 이름보다 실제 수명과 권한을 봐야 해.
단기 토큰이 알맞은 경우
최종 사용자 세션 — 로그인 뒤 제한된 시간 동안 액세스 토큰을 사용하고, 필요하면 갱신하거나 다시 인증해.
OAuth 위임 접근 — 제3자 앱이 사용자 동의를 받아 제한된 범위에서 대신 행동해. 사용자는 권한 부여를 철회할 수 있고 액세스 토큰도 만료돼.
사용자별·범위별 권한 — 각 토큰이 특정 사용자와 대상, 권한 범위를 나타내므로 최소 권한을 적용하기 쉬워.
모바일 앱이나 SPA — 장기 클라이언트 비밀값을 숨길 수 없는 공개 클라이언트에서는 짧은 수명의 토큰과 안전한 갱신·저장 전략이 필요해.
OAuth 2.0 액세스 토큰과 짧게 만료되는 세션 토큰, cwkPippa의 PIN 발급 Bearer 토큰이 이 패턴에 들어가. JWT인지 불투명 토큰인지는 수명과 별개의 선택이야.
구현 편의보다 누출 위험을 기준으로 선택해. API 키는 생성하고 복사해 설정하면 끝이라 간단해 보여. 하지만 스크린샷, 브라우저 확장, 로그, 프로젝트 파일을 읽는 도구에 노출되면 폐기할 때까지 피해가 이어질 수 있어. 단기 토큰 흐름은 구성 요소가 더 많지만, 올바르게 설계하면 권한과 악용 가능 시간을 제한할 수 있어. 어느 이름이 더 안전한지가 아니라, 실제 수명 주기와 저장 경계를 비교해야 해.
교체와 폐기 — 자격 증명 운영의 절반
API 키와 토큰 모두 교체·폐기 정책이 필요해:
API 키: 통합별로 여러 활성 키를 지원해 중단 없이 교체하고, 생성 시각과 마지막 사용 시각, 권한 범위, 사용 로그를 보여줘. 교체 주기는 위험과 규정에 맞추되 자동화할수록 좋아.
토큰: 액세스 토큰은 짧게 만료시키고, 더 긴 수명의 갱신 토큰은 새 액세스 토큰을 받을 때만 사용해. 갱신 토큰 회전과 재사용 탐지, 로그아웃·침해 시 폐기도 함께 설계해야 해.
핵심 패턴은 겹치는 유효 기간을 이용한 무중단 API 키 교체와, 단기 액세스 토큰을 더 오래가는 갱신 토큰과 분리하는 방식이야. 어느 쪽이든 발급만 하고 교체·폐기를 빼먹으면 운영 보안이 완성되지 않아.
cwkPippa의 조합
cwkPippa는 사용자 세션용 단기 Bearer 토큰과 외부 서비스 호출용 서버 자격 증명을 함께 사용해. PIN 로그인으로 발급한 토큰은 24시간 뒤 만료되고 갱신할 수 있어. 외부 제공자 자격 증명은 백엔드가 .env를 통해 읽으며 프런트엔드로 전달하지 않아. 사용자 토큰이 노출되면 해당 세션이 위험해지고, 결제 가능한 공급자 자격 증명이 노출되면 폐기할 때까지 비용과 데이터 접근 피해가 이어질 수 있어. 서로 다른 위험에 맞춰 저장 위치와 수명, 폐기 절차를 다르게 둔 구성이야.
Code
한 엔드포인트에서 API 키와 단기 토큰을 Bearer로 받기·python
# Server 쪽 — 한 endpoint 에 장기 API key 와 단기 token 둘 다 받음
from fastapi import FastAPI, Depends, HTTPException, status, Header
from typing import Annotated
app = FastAPI()
# 가상 store; production: DB + Redis
_api_keys: set[str] = {'sk_live_abc123_owned_by_org_xyz'}
_active_tokens: dict[str, str] = {'tok_short_lived_xyz': 'u_42'}
async def authenticate(
authorization: Annotated[str | None, Header()] = None,
) -> dict:
if not authorization or not authorization.startswith('Bearer '):
raise HTTPException(401, detail='Bearer 필수',
headers={'WWW-Authenticate': 'Bearer'})
credential = authorization.removeprefix('Bearer ').strip()
# API key 먼저 시도 (장기, server-to-server)
if credential in _api_keys:
return {'kind': 'api_key', 'identity': 'org_xyz'}
# 단기 token 으로 fallback (user 세션)
if credential in _active_tokens:
return {'kind': 'session_token', 'identity': _active_tokens[credential]}
raise HTTPException(401, detail='알 수 없는 자격 증명',
headers={'WWW-Authenticate': 'Bearer'})
@app.get('/account')
async def me(auth = Depends(authenticate)):
return auth
단기 액세스 토큰과 갱신 토큰을 분리한 세션·python
# 단기 token 발급 + refresh
import secrets, time
from fastapi import FastAPI, HTTPException
app = FastAPI()
_users: dict[str, dict] = {'pippa': {'password_hash': '...', 'id': 'u_42'}}
_tokens: dict[str, dict] = {} # token -> {user_id, issued_at, expires_at}
_refresh: dict[str, str] = {} # refresh_token -> user_id
@app.post('/login')
async def login(payload: dict):
user = _users.get(payload['username'])
if not user or not verify_password(payload['password'], user['password_hash']):
raise HTTPException(401, detail='잘못된 자격 증명')
access = secrets.token_urlsafe(32)
refresh = secrets.token_urlsafe(32)
now = time.time()
_tokens[access] = {'user_id': user['id'], 'issued_at': now, 'expires_at': now + 900} # 15분
_refresh[refresh] = user['id'] # 실생활 30일 — expiry 같이 저장
return {'access_token': access, 'refresh_token': refresh, 'expires_in': 900}
@app.post('/refresh')
async def refresh_endpoint(payload: dict):
user_id = _refresh.get(payload['refresh_token'])
if not user_id:
raise HTTPException(401, detail='잘못된 refresh token')
new_access = secrets.token_urlsafe(32)
now = time.time()
_tokens[new_access] = {'user_id': user_id, 'issued_at': now, 'expires_at': now + 900}
return {'access_token': new_access, 'expires_in': 900}
앞에서 만든 FastAPI 인증을 확장해 장기 API 키와 단기 세션 토큰을 모두 지원해. API 키는 시작할 때 환경 변수에서 읽고, 세션 토큰은 /login에서 발급해 15분 뒤 만료되게 만들어. /refresh에서는 갱신 토큰으로 새 액세스 토큰을 발급해야 해. 두 자격 증명 모두 같은 Authorization: Bearer ... 헤더로 받으면 돼. 성공한 API 키 접근, 성공한 단기 토큰 접근, 만료된 토큰의 401, 다시 로그인하지 않고 접근을 복구하는 갱신 흐름을 각각 시험해봐. 보너스로 비밀값 자체는 남기지 않은 채 요청에 사용된 자격 증명 종류와 키 식별자만 기록해.
Hint
먼저 장기 API 키 저장소에서 찾아보고, 없으면 만료 검사가 있는 단기 토큰 저장소를 확인한 뒤, 어느 쪽에도 없으면 401을 반환해. /refresh는 갱신 토큰을 받은 뒤 새 액세스 토큰만 발급해야 해. 갱신 토큰은 수명이 더 길지만 일반 API 접근에는 사용할 수 없도록 제한하고, 가능하면 회전과 재사용 탐지도 추가해. 자격 증명 종류를 기록하면 브라우저 요청에 서버용 API 키가 잘못 사용되는 구성 오류를 찾는 데 도움이 돼.
Progress
Progress is local-only — sign in to sync across devices.