콘텐츠로 이동
Search

Supabase:JWTSigningKeys

Supabase Auth의 JWT Signing Keys는 레거시 단일 JWT secret(HS256)을 대체하는 서명 키 관리 체계다. 핵심은 세 가지다.

  • 서명을 비대칭키(ES256/RS256)로 한다.
  • 여러 키를 상태별로 교대(rotate)하며 쓴다.
  • 이와 별개로 API 키(anon/service_role)가 JWT가 아닌 키(sb_publishable_…/sb_secret_…)로 분리된다.

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 = 대칭키

서명 키와 검증 키가 같다. 검증만 하려는 서버도 이 비밀을 가져야 하고, 비밀을 가진 쪽은 아무 토큰이나 위조할 수 있다(role: service_role 포함).

유출돼도 모름

비밀이 환경변수, git, VITE_/NEXT_PUBLIC_ 접두사 실수 등으로 새도 알아챌 방법이 없다.

회전이 고통스러움

secret을 바꾸면 anon/service_role 키가 동시에 무효가 되고 모든 사용자가 로그아웃된다. 모바일 앱은 스토어 심사 기간 내내 먹통이 된다.

대칭키와 비대칭키

  • HS256 (대칭): 도장 하나를 모두가 나눠 갖는 방식이다. 진위를 확인하려면 도장이 있어야 하는데, 도장을 가진 사람은 곧바로 위조도 할 수 있다.
  • ES256 / RS256 (비대칭): 인감(private key)은 Supabase만 갖고, 인감증명(public key)은 누구에게나 공개한다. 누구나 진위를 확인할 수 있지만 위조는 못 한다.

공개키는 JWKS 엔드포인트로 공개된다.

GET https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json

JWT 헤더의 kid(key id)가 JWKS 안의 어느 공개키로 검증할지 알려 준다.

{ "alg": "ES256", "kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e", "typ": "JWT" }

지원 알고리즘

알고리즘

JWT alg

비고

NIST P-256

ES256

권장 기본값. RSA보다 서명이 훨씬 짧고 빠르다.

RSA 2048

RS256

지원 범위가 가장 넓지만 느리다.

Ed25519

EdDSA

준비 중(런타임 지원이 제한적).

HMAC 공유 시크릿

HS256

프로덕션 비권장. 폐기와 유출 탐지가 어렵다.

새 체계는 두 갈래로 나뉜다

헷갈리기 쉬운 지점이다. 서로 독립된 두 가지 변화가 있다.

(a) API 키: anon/service_role → publishable/secret

레거시

새 키

성격

anon (JWT)

sb_publishable_…

JWT가 아닌 불투명한 문자열

service_role (JWT)

sb_secret_…

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분 뒤에 한다.

레거시에서 옮기는 순서

  1. 대시보드 Project Settings → JWT Keys → "Migrate JWT secret"
    • 기존 secret을 새 체계에 current로 등록한다. 서명 키가 그대로라 아무것도 바뀌지 않고 무중단이다.
    • 동시에 Supabase가 ES256 standby 키를 하나 자동으로 만든다.
  2. "Rotate keys": 새 로그인부터 ES256으로 서명된다. 레거시 secret은 previously used가 된다.
  3. 토큰 만료 시간(기본 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를 직접 서명해야 한다면 다음 순서를 따른다.

  1. 내가 ES256 키 쌍을 만든다. 예: supabase gen signing-key --algorithm ES256. WebCrypto generateKey로 만들어도 된다.
  2. private key(JWK)를 대시보드에서 standby 키로 임포트한다.
  3. (문서 절차) rotate로 활성화한다.
  4. 서명할 때 헤더의 kid를 임포트한 키와 똑같이 맞춘다(필수).
  5. payload에 필수 클레임을 넣는다: role(anon/authenticated/service_role 등 기존 Postgres 롤), exp, (선택) sub(auth.users의 UUID).
  6. 요청할 때는 Authorization: Bearer<JWT>와 함께 apikey 헤더도 따로 보낸다. JWT가 apikey를 대신하지 못한다.

임포트 형식(private JWK JSON 한 줄):

{"kty":"EC","crv":"P-256","kid":"<uuid>","d":"<base64url>","x":"<base64url>","y":"<base64url>"}
  • 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) 형식을 요구한다. WebCrypto crypto.subtle.sign({name:'ECDSA', hash:'SHA-256'})의 출력은 이 형식이라 그대로 base64url 하면 된다. Node crypto.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초).

See also

Favorite site

lastmod 2026-09-10created 2026-09-10author Zer0 sitemap