API 키와 OAuth는 서로 다른 문제를 풉니다. 둘 중 무엇을 쓸지는 한 가지 질문에서 갈립니다. 이 구도에 제삼자가 있는가.

우리 서버가 자기 이름으로 남의 API를 부른다면 키입니다. 남의 애플리케이션이 우리 사용자의 이름으로 우리 API를 부른다면 OAuth입니다. 제삼자가 없는 자리에서 OAuth는 절차만 늘릴 뿐 안전을 늘리지 않습니다.

아래에서는 둘이 실제로 어디서 갈라지는지, 최근 몇 년 사이 OAuth가 무엇을 바꿨는지, 그리고 API 키에 관해 거의 아무도 쓰지 않지만 운영 환경에서의 동거 방식을 결정해 버리는 성질 하나를 다룹니다.

API 키: "이 프로그램은 허용됨"이라고 말하는 문자열 한 줄

키는 요청 헤더에 넣는 길고 무작위한 문자열입니다.

X-Api-Key: lix_live_9f2c...

서버는 그 키를 찾아 어느 계정의 것인지 알아내고 요청을 통과시킵니다. 구조는 여기까지입니다.

여기서 따라 나오는 성질들입니다.

  • 키는 스스로 만료되지 않는다. 누군가 폐기할 때까지 살아 있습니다;
  • 키가 가리키는 것은 프로그램이지 사람이 아니다. 팀에서 누가 호출했는지는 보통 드러나지 않습니다;
  • 키는 계정이 할 수 있는 모든 것을 허용한다. 따로 권한을 좁히지 않았다면요;
  • 키는 유일한 비밀이다. 새어 나가면 접근 권한이 새어 나갑니다. 중간 단계란 없습니다.

OAuth: 동의에 관한 규약

OAuth 2.0(RFC 6749)은 다른 질문에 답합니다. 참여자는 넷입니다. 데이터의 주인, 그것을 원하는 애플리케이션, 인가 서버, 그리고 데이터를 쥔 서버.

흐름은 대략 이렇습니다. 애플리케이션이 사용자를 서비스 자신의 페이지로 보냅니다. 사용자는 누가 무엇을 요구하는지 보고 동의합니다. 애플리케이션은 코드를 받아 액세스 토큰으로 바꾸고, 그 토큰으로 API를 호출합니다. 토큰의 수명은 짧아서 몇 분에서 몇 시간이며, 만료되면 리프레시 토큰으로 새것을 받습니다.

이것이 주는 것 중에 키가 원리상 줄 수 없는 것들이 있습니다.

  • 사용자의 비밀번호가 애플리케이션에 전달되지 않는다. 의미는 전부 여기에 있습니다;
  • 권한이 스코프로 쪼개진다. "링크 읽기"와 "링크 삭제"는 서로 다른 허가입니다;
  • 동의가 눈에 보이고 거둘 수 있다. 사용자는 연결된 애플리케이션 목록을 보고, 나머지를 건드리지 않은 채 하나만 끊을 수 있습니다;
  • 토큰은 알아서 만료된다. 훔친 액세스 토큰도 한 시간 뒤에는 쓸모가 없습니다.

대가는 복잡성입니다. 인가 서버, 애플리케이션 등록, 동의 화면, 리프레시 토큰의 보관과 교체, 그리고 호출마다 만료 처리가 필요합니다.

결정을 실제로 가르는 축들의 비교

API 키 OAuth 2.0
무엇을 증명하는가 프로그램 프로그램이 대리하는 사용자
수명 무기한 액세스 토큰 몇 분~몇 시간
접근을 끊는 방법 키 폐기 동의 또는 토큰 철회
권한 범위 보통 계정 전체 동작별 스코프
제삼자가 쥐는 비밀 키 그 자체 토큰뿐, 비밀번호는 아님
도입 비용 헤더 하나 인가 서버와 전체 주기
어울리는 자리 서버 대 서버, 스크립트, 사내 연동 공개 앱, 마켓플레이스, "○○으로 로그인"

고르는 규칙

물어야 할 것은 추상적으로 무엇이 더 안전한가가 아니라, 누가 누구에게 접근을 위임하는가입니다.

우리 서버 → 남의 API, 계정은 우리 것
    → API 키

남의 앱 → 우리 API, 계정은 사용자 것
    → OAuth

예약 작업, CI, 백엔드 연동
    → API 키

사용자가 앱 단위로 권한을 보고 거둬야 함
    → OAuth

포스트백 수신은 키의 교과서적 사례입니다. 당신의 서버가 우리 서버에 "이 주문은 결제되었다"고 알릴 때 그 교환에는 사용자가 한 명도 없고, 동의할 사람도 없으며, 동의 화면을 둘 자리도 없습니다. 반대 방향의 webhook도 마찬가지입니다.

눈을 뗀 사이 OAuth는 달라졌다

이 주제를 다루는 글의 절반은 2015년의 OAuth를 설명합니다. 그 뒤로 상당히 좁아졌습니다.

2025년 1월 IETF는 RFC 9700, Best Current Practice for OAuth 2.0 Security를 냈습니다. 이 문서는 실제 공격에서 쌓인 경험을 한데 모으고, 예전에는 용인되던 두 가지 방식을 공식적으로 폐기합니다.

  • implicit grant — 토큰을 주소창에 그대로 돌려주던 방식;
  • resource owner password credentials — 애플리케이션이 사용자에게 아이디와 비밀번호를 직접 묻는 방식. OAuth가 없애려던 바로 그것입니다.

동시에 PKCE가 예전처럼 모바일에만이 아니라, 서버 쪽을 포함한 모든 클라이언트 종류에 대해 의무가 되었습니다.

따로 알아 둘 만한 것은 OAuth 2.1이 아직 초안이라는 사실입니다. 발행된 RFC가 아닙니다. 같은 변경들을 한 문서에 모은 것으로, PKCE 의무화, 리다이렉트 URI의 정확한 문자열 비교, implicit와 password grant 제거, 질의 문자열에 토큰 금지가 담깁니다. 현행 표준인 양 인용하기에는 이릅니다.

실무적 결론은 간단합니다. implicit flow를 권하는 OAuth 안내서를 읽고 있다면, 그 안내서는 낡았습니다.

잘 언급되지 않는 API 키의 약점

키는 만료되지 않습니다. 그러니 폐기가 유일한 지렛대입니다. 그런데 여기서 불편한 사실이 나옵니다. 폐기는 대개 즉각적이지 않습니다.

요청마다 키를 확인한다는 것은 매번 데이터베이스를 조회한다는 뜻입니다. 실제 트래픽이 있는 API라면 그 결과를 캐시합니다. 우리의 경우 키와 계정의 대응이 캐시에 5분간 머뭅니다. 즉 "폐기"를 누른 시점과 접근이 실제로 막히는 시점 사이에 최대 5분이 있고, 그동안 유출된 키는 계속 작동합니다.

이것은 실수가 아니라 맞바꿈입니다. 캐시가 없으면 모든 요청이 데이터베이스를 두드립니다. 키 방식의 API는 대체로 비슷한 거래를 하고 있으며, 다만 소리 내어 말하지 않을 뿐입니다. 알아 둘 이유는 둘입니다. 첫째, 실제 유출이 일어나면 키 폐기는 마지막 행동이 아니라 첫 행동이고, 그다음에는 그 몇 분 동안 무슨 일이 있었는지 확인해야 합니다. 둘째, 이것이야말로 OAuth의 짧은 수명 토큰이 반대편에서 푸는 문제입니다. 그쪽은 데이터베이스를 거치지 않고 스스로 만료됩니다.

키와 탈 없이 지내는 법

키로 충분하다면 — 그리고 대부분의 연동에서는 충분합니다 — 최소한 이 정도는 지킵니다.

  1. 해시만 저장하세요. 서버는 받은 키의 해시를 저장된 것과 대조하고, 원래 문자열은 생성 시 한 번만 사용자에게 보여 줍니다. 우리는 SHA-256으로 대조합니다.
  2. 키에 접두사를 붙이세요. lix_live_... 형태의 문자열은 저장소와 로그 파이프라인의 비밀 탐지기가 알아봅니다.
  3. 연동마다 별도의 키를 두세요. 하나를 폐기해도 나머지가 함께 쓰러지지 않고, 기록에서 무엇이 털렸는지 정확히 보입니다.
  4. 키를 질의 문자열에 넣지 마세요. 주소는 웹 서버 로그, Referer 헤더, 브라우저 기록에 남습니다. 헤더만 쓰십시오.
  5. 사고가 아니라 일정에 따라 교체하세요. 한 번이라도 교체해 본 키는 다음에도 빨리 바뀝니다. 한 번도 바꾸지 않은 키는 결국 네 군데에 박혀 있는 것으로 드러납니다.
  6. 마지막 사용 시각을 보세요. 반년 동안 건드리지 않은 키는 예비가 아니라 열린 문입니다.

우리가 쓰는 방식

Lix.li API는 키 인증입니다. X-Api-Key 헤더, 관리 화면에서의 생성과 폐기, 해시 대조, 키별 개별 요청 한도. OAuth는 없고 이는 의도한 결정입니다. 이 API의 쓰임새는 서버 쪽에 있고 — 링크 만들기, 통계 가져오기, 전환 받기 — 그중 어디에도 사용자가 동의를 줘야 할 제삼자가 등장하지 않기 때문입니다.

남의 애플리케이션을 고객 계정에 들여야 하는 과제가 생긴다면 키로는 모자랍니다. 사용자는 누구에게 무엇을 허락했는지 볼 수 있어야 하고, 나머지를 망가뜨리지 않은 채 앱 하나만 끊을 수 있어야 합니다. 그 지점에서 비로소 OAuth는 절차이기를 그만두고 유일하게 정직한 선택지가 됩니다.

호출 자체는 API 문서에 정리되어 있으며, 잘못된 키와 폐기된 키에 각각 어떤 응답 코드가 오는지도 거기서 볼 수 있습니다. 이웃한 주제로 301 리다이렉션이 POST를 GET으로 바꿔 버리는 이유는 포스트백 수신을 구성하는 사람이라면 읽어 둘 만합니다. 인증이 붙은 요청이 보낼 때와 다른 메서드로 도착할 수 있기 때문입니다.