Supabase:JWTSigningKeys
Supabase Auth의 JWT Signing Keys는 레거시 단일 JWT secret(HS256)을 대체하는 서명 키 관리 체계다. 핵심은 세 가지다.
- 서명을 비대칭키(ES256/RS256)로 한다.
- 여러 키를 상태별로 교대(rotate)하며 쓴다.
- 이와 별개로 API 키(
anon/service_role)가 JWT가 아닌 키(sb_publishable_…/sb_secret_…)로 분리된다.
- 원문: https://supabase.com/docs/guides/auth/signing-keys (2026-09-10 확인)
- 전제 지식: JWT
TOC
한 줄 요약
지금까지는 비밀번호 하나(JWT secret)로 모든 토큰에 서명했다. 새 체계에서는 공개키 서명을 쓰고, 키를 무중단으로 교대할 수 있다. 그리고 anon/service_role 키는 더 이상 JWT가 아니다.
레거시 구조
대시보드의 JWT secret 하나(HS256, 대칭키)가 모든 토큰에 서명한다.
JWT secret (HS256, 대칭키 하나)
│ 서명
┌─────────────┼──────────────────┐
▼ ▼ ▼
anon 키 service_role 키 로그인 사용자 세션 토큰
{role:anon} {role:service_role} {role:authenticated, sub:<uuid>}
만료 10년 만료 10년 만료 1시간
잘 알려지지 않은 사실이 있다. anon 키와 service_role 키도 사실은 JWT다. jwt.io에 붙여 넣으면 {"role":"anon", "exp": 10년 뒤} 같은 payload가 보인다. PostgREST는 이 role 클레임을 보고 SET ROLE anon / service_role을 한다.
레거시의 문제
| 문제 | 설명 |
| HS256 = 대칭키 | 서명 키와 검증 키가 같다. 검증만 하려는 서버도 이 비밀을 가져야 하고, 비밀을 가진 쪽은 아무 토큰이나 위조할 수 있다( |
| 유출돼도 모름 | 비밀이 환경변수, git, |
| 회전이 고통스러움 | secret을 바꾸면 anon/service_role 키가 동시에 무효가 되고 모든 사용자가 로그아웃된다. 모바일 앱은 스토어 심사 기간 내내 먹통이 된다. |
대칭키와 비대칭키
- HS256 (대칭): 도장 하나를 모두가 나눠 갖는 방식이다. 진위를 확인하려면 도장이 있어야 하는데, 도장을 가진 사람은 곧바로 위조도 할 수 있다.
- ES256 / RS256 (비대칭): 인감(private key)은 Supabase만 갖고, 인감증명(public key)은 누구에게나 공개한다. 누구나 진위를 확인할 수 있지만 위조는 못 한다.
공개키는 JWKS 엔드포인트로 공개된다.
JWT 헤더의 kid(key id)가 JWKS 안의 어느 공개키로 검증할지 알려 준다.
지원 알고리즘
| 알고리즘 | JWT | 비고 |
| NIST P-256 | | 권장 기본값. RSA보다 서명이 훨씬 짧고 빠르다. |
| RSA 2048 | | 지원 범위가 가장 넓지만 느리다. |
| Ed25519 | | 준비 중(런타임 지원이 제한적). |
| HMAC 공유 시크릿 | | 프로덕션 비권장. 폐기와 유출 탐지가 어렵다. |
새 체계는 두 갈래로 나뉜다
헷갈리기 쉬운 지점이다. 서로 독립된 두 가지 변화가 있다.
(a) API 키: anon/service_role → publishable/secret
| 레거시 | 새 키 | 성격 |
| | | JWT가 아닌 불투명한 문자열 |
| | | JWT가 아닌 불투명한 문자열 |
- API 게이트웨이가 키를 확인한 뒤, 내부적으로 짧은 수명의 JWT를 새로 만들어 PostgREST 등에 넘긴다. DB에서는 여전히
anon/service_role롤로 보인다. - 서명 키와 무관하므로 키마다 따로 발급, 폐기, 교체할 수 있다.
- 레거시 키와 병행 운영이 가능해서 무중단으로 전환할 수 있다. 대시보드의 "last used" 표시로 사용 여부를 확인한 뒤 끄면 된다.
(b) JWT 서명 키: JWT secret → Signing Keys
사용자 세션 토큰(authenticated)을 무엇으로 서명하느냐의 문제다. 이 문서의 본론이다.
키 생애주기
| 상태 | 새 토큰 서명 | 기존 토큰 검증 | JWKS 공개 |
| Standby | ✗ | (다음 차례 대기) | ✓ 미리 공개 |
| Current (In use) | ✓ | ✓ | ✓ |
| Previously used | ✗ | ✓ (남은 토큰용) | ✓ |
| Revoked | ✗ | ✗ | ✗ |
회전(rotate) 흐름
시간 →
키 A: [ current ]──rotate──[ previously used ]────(1시간+여유 뒤)──[ revoked ]
키 B: [ standby ]──rotate──[ current ────────────────────────────────────── ]
↑
이 순간부터 새 로그인은 B로 서명.
A로 서명된 기존 토큰도 계속 통과 → 아무도 로그아웃되지 않음
- standby를 미리 공개하는 이유: 검증하는 쪽 캐시에 B의 공개키가 먼저 퍼져 있어야, rotate 직후 B로 서명한 토큰이 거부되지 않는다.
- 영구 삭제를 제외한 모든 상태 변경은 되돌릴 수 있다. 문제가 생기면 롤백한다.
- 상태를 바꾸면 약 5분의 제한(throttle)이 걸린다(플랫폼 전체 동기화).
JWKS 캐시 (최대 약 20분)
- Supabase 엣지에서 10분, 클라이언트 라이브러리 메모리에서 10분, 합쳐 최대 약 20분 캐시된다.
- Supabase 자체 제품(Auth, Data API, Storage, Realtime)은 이 캐시에 의존하지 않으므로 revoke가 즉시 반영된다.
- 반면 직접 만든 백엔드가 JWKS를 캐시해서 검증하면, revoke한 키를 최대 20분 더 믿을 수 있다. 긴급 폐기가 필요하면 캐시 무효화 수단을 따로 둔다.
- 키를 임포트하거나 revoke한 직후의 실패는 키 문제가 아닐 수 있다. 실측은 20분 뒤에 한다.
레거시에서 옮기는 순서
- 대시보드 Project Settings → JWT Keys → "Migrate JWT secret"
- 기존 secret을 새 체계에 current로 등록한다. 서명 키가 그대로라 아무것도 바뀌지 않고 무중단이다.
- 동시에 Supabase가 ES256 standby 키를 하나 자동으로 만든다.
- "Rotate keys": 새 로그인부터 ES256으로 서명된다. 레거시 secret은 previously used가 된다.
- 토큰 만료 시간(기본 1시간)에 여유를 더한 만큼(예: 1시간 15분) 기다린 뒤 레거시 secret을 revoke한다.
- 이때 레거시 anon/service_role 키부터 꺼야 한다. 둘 다 레거시 secret으로 서명된 JWT라서, secret을 revoke하면 같이 죽는다. 즉 먼저 publishable/secret 키로 갈아타야 한다.
- 레거시 JWT secret은 삭제할 수 없다(롤백 대비).
Rotate 전 체크리스트
- 코드에서
jose/jsonwebtoken등에 JWT secret을 직접 넣어 검증하고 있으면 rotate 순간 깨진다.supabase.auth.getClaims()나 JWKS 기반 검증으로 바꾼다. - Edge Functions의 "Verify JWT" 옵션이 켜져 있으면 끄고, 함수 안에서 직접 검증한다.
직접 JWT를 만들어야 할 때 (Private key Import)
대시보드가 만든 키는 private key를 꺼낼 수 없다(컴플라이언스 목적). 꺼낼 수 있는 건 레거시 secret뿐이다. 내 서버가 JWT를 직접 서명해야 한다면 다음 순서를 따른다.
- 내가 ES256 키 쌍을 만든다. 예:
supabase gen signing-key --algorithm ES256. WebCryptogenerateKey로 만들어도 된다. - private key(JWK)를 대시보드에서 standby 키로 임포트한다.
- (문서 절차) rotate로 활성화한다.
- 서명할 때 헤더의
kid를 임포트한 키와 똑같이 맞춘다(필수). - payload에 필수 클레임을 넣는다:
role(anon/authenticated/service_role등 기존 Postgres 롤),exp, (선택)sub(auth.users의 UUID). - 요청할 때는
Authorization: Bearer<JWT>와 함께apikey헤더도 따로 보낸다. JWT가 apikey를 대신하지 못한다.
임포트 형식(private JWK JSON 한 줄):
-
d가 private 스칼라다.kty·crv·kid·x·y가 JWKS에 공개된다. - CLI로 테스트 토큰을 만들 수도 있다:
supabase gen bearer-jwt --role authenticated --sub<uuid>
실측으로 확인한 사실 (2026-09-11)
문서에 명시되지 않은 부분이다.
- 임포트 UI는 "Migrate JWT secret" 뒤에만 나온다. 레거시 단독 상태에서는 Migrate 버튼만 보인다.
- standby 슬롯은 1개다. 개발 키와 프로덕션 키를 따로 standby로 둘 수 없다.
- standby 상태로 임포트한 키로 서명한 JWT도 Data API(PostgREST)가 받아들인다. rotate하지 않아도 되고, Supabase 세션 서명은 Supabase 자기 키가 계속 맡는다. 다만 문서가 보장하는 동작이 아니므로, 정책이 바뀌면 멈출 수 있다.
구현 함정
- ECDSA 서명 형식: JWS ES256은 64바이트
r‖s(IEEE P1363) 형식을 요구한다. WebCryptocrypto.subtle.sign({name:'ECDSA', hash:'SHA-256'})의 출력은 이 형식이라 그대로 base64url 하면 된다. Nodecrypto.sign의 기본 출력은 DER이라 그대로 쓰면 서명이 통째로 거부된다(dsaEncoding:'ieee-p1363'필요). - JWK를
importKey할 때alg필드는 빼는 편이 안전하다(일부 구현이 거절). - private JWK는 파일로 남기지 않는다. 시크릿 등록은 인자 대신 stdin으로 넘겨 셸 히스토리에 남지 않게 한다.
API 키와 서명 키의 관계 (요청 한 건의 검증 경로)
Supabase 입장에서는 요청 헤더 두 개를 각각 다른 장치가 검사한다.
요청 ─▶ [API 게이트웨이] ─────────────────────▶ [PostgREST]
│ apikey 헤더 확인 │ Bearer JWT 서명 검증
│ (sb_publishable / sb_secret) │ (JWT 서명 키 = 이 문서의 주제)
│ │
└ sb_secret 이면 → service_role 용 └ 통과하면 JWT의 role 로 SET ROLE
단기 JWT를 게이트웨이가 직접 만들어 전달 (authenticated + sub → RLS)
-
apikey: "이 프로젝트에 들어올 자격이 있는가"를 본다. API 키 체계가 담당한다. -
Bearer: "누구로서 들어왔는가"를 본다. JWT 서명 키 체계의 영역이다. - service client(
sb_secret_…)는 Bearer를 따로 보내지 않는다. 게이트웨이가 service_role JWT를 자기 키로 직접 만들어 넘기므로, 서명 키 교체와 무관하다.
이미 새 API 키를 쓰고 있다면
sb_publishable_…/sb_secret_…는 JWT가 아니므로 Migrate, rotate, 레거시 revoke 모두의 영향을 받지 않는다. 변수 이름이 SUPABASE_SERVICE_ROLE_KEY여도 값이 sb_secret_이면 된다.
주의할 것은 레거시 anon/service_role 키가 대시보드에 여전히 살아 있다는 점이다. 따로 끄지 않았다면 유효한 JWT다. 특히 레거시 service_role은 10년짜리 만능 키라서, 새면 막을 방법이 사실상 끄는 것뿐이다. 끄기 전에는 두 가지를 확인한다.
- 대시보드 API Keys의 "last used" 표시로 최근 사용 여부를 본다.
- 프로덕션 시크릿 값이
eyJ…(레거시 JWT)인지sb_secret_인지 확인한다.eyJ…라면 레거시 secret을 revoke하는 순간 그 서버가 멈춘다.
회전과 폐기가 필요한 경우
- 유출: 레거시 secret이 로그, 소스 관리, 프론트 빌드에 들어갔거나 퇴사자가 알고 있는 경우.
- 컴플라이언스: 연 1회 회전 등(SOC2, PCI-DSS, ISO27000, HIPAA).
- 알고리즘 변경: RSA, EC, 공유 시크릿 사이의 전환.
적용 사례: CVP CLI PAT
- 상황: CLI 개인 액세스 토큰(
cvp_pat_…)은 자체 자격증명이라, Cloudflare Worker가 그 사용자의 Supabase JWT를 직접 민팅해 PostgREST에 태운다(RLS 유지). - 선택: 레거시 HS256 secret은 쓰지 않았다(모든 토큰을 서명하는 공유 키라서). 대신 직접 만든 ES256 키를 standby로 임포트하고, Worker 시크릿(
SUPABASE_JWT_SIGNING_KEY, JWK 한 줄)에만 private key를 둔다. - 결과: Supabase 세션 서명은 그대로 두고, 우리 민팅 키만 따로 revoke할 수 있다. standby 슬롯이 1개라 개발과 프로덕션이 같은 키를 쓴다.
- 요청 구성:
apikey: sb_publishable_…+Authorization: Bearer<민팅한 ES256 JWT>(TTL 600초).