~/web-app-dev.md
WEB_APP_DEV

PostgREST 13의 `kid` 검증은 JWT를 “키 계약”으로 보게 만든다

10분 읽기·2026.07.10·출처 4·00
오늘의 질문

RLS 정책은 맞는데 Supabase REST 요청이 갑자기 anon처럼 보인다면, 토큰의 어느 부분부터 확인해야 할까?

web-app-dev.md
JWT key contract

PostgREST 13의 kid 검증은 JWT를 “키 계약”으로 보게 만든다

1. 왜 지금 봐야 하나

Supabase의 Data API는 PostgREST 위에서 동작한다. 그래서 PostgREST의 인증 동작 변화는 단순한 백엔드 내부 업데이트가 아니라, 프론트엔드에서 supabase.from('table')을 호출하는 순간의 권한 판단까지 영향을 준다.

PostgREST 13에서 눈에 띄는 변경 중 하나는 JWT header의 kid 검증이다. Supabase의 PostgREST 13 소개 글은 “JWT에 Key ID인 kid가 있으면 JSON Web Key Set 안의 kid 중 하나와 매칭하려고 한다”고 설명한다. PostgREST 13 인증 문서도 같은 흐름을 적고 있다. JWT에 kid가 있으면 jwt-secret에 들어 있는 JWK/JWKS에서 같은 kid를 찾고, 맞는 키가 없으면 토큰은 거부된다. JWT에 kid가 없으면 여러 키를 순서대로 시도한다.

이 말은 웹앱 개발자에게 꽤 현실적인 장애 시나리오로 바뀐다.

로그인은 성공했는데 REST 요청에서는 auth.uid()가 비어 보이거나 RLS가 403을 낸다. 앱 코드는 그대로인데, 키 회전이나 커스텀 JWT 설정 이후부터만 깨진다.

오늘의 핵심 문장: JWT는 “사용자 정보가 담긴 문자열”이 아니라, 어떤 서명 키로 검증해야 하는지까지 포함한 키 계약이다.

2. 핵심 개념

JWT를 볼 때 보통 payload만 확인한다. sub가 있는지, roleauthenticated인지, exp가 지나지 않았는지 본다. 하지만 서명 검증 관점에서는 header도 중요하다.

JWT는 크게 세 부분이다.

zsh — 생존확인.sh
header.payload.signature

여기서 header에는 보통 이런 값이 들어간다.

zsh — 생존확인.sh
{
  "alg": "ES256",
  "typ": "JWT",
  "kid": "some-key-id"
}
  • alg: 어떤 알고리즘으로 서명했는가
  • kid: 여러 키 중 어떤 키로 검증해야 하는가
  • signature: payload가 위조되지 않았음을 증명하는 값

Supabase의 JWT Signing Keys 문서는 legacy JWT secret과 새로운 signing keys 시스템을 구분한다. legacy 방식은 하나의 공유 비밀값으로 anon, service_role, 사용자 access token까지 서명한다. 새로운 signing keys 방식은 비대칭 키를 권장하며, private key는 토큰 서명에, public key는 검증에 사용한다. 장점은 로컬 검증, 더 나은 회전, API key와 JWT signing key의 분리다.

하지만 이 구조에서는 “검증자가 어떤 공개키를 써야 하는가?”가 중요해진다. 그 선택 힌트가 kid다.

PostgREST 13의 더 엄격한 kid 검증은 이 계약을 명시적으로 만든다.

  1. JWT header에 kid가 있다.
  2. PostgREST가 설정된 JWK/JWKS에서 같은 kid를 찾는다.
  3. 같은 kid의 키가 있으면 그 키로 서명을 검증한다.
  4. 없거나 키 타입/알고리즘이 맞지 않으면 401 또는 인증 실패 흐름으로 이어진다.

즉 “토큰 payload는 멀쩡해 보인다”와 “Data API가 토큰을 신뢰한다”는 같은 말이 아니다.

3. 최신 이슈와 연결

PostgREST 13은 인증 변화만 있는 릴리스가 아니다. Supabase 소개 글은 다음 기능도 함께 설명한다.

  • to-many relationship을 flat JSON array로 펼치는 spread 기능
  • text/json/jsonb에 대한 full-text search 자동 tsvector 변환
  • update/delete/rpc에서 너무 많은 row를 바꾸지 않게 막는 maxAffected
  • 응답 크기를 볼 수 있는 Content-Length
  • 오류 코드를 Proxy-Status header에 담아 관측성을 높이는 변화

이 중 maxAffectedProxy-Status는 제품 운영에서 바로 도움이 된다. 예를 들어 Prefer: handling=strict, max-affected=1은 “사용자 한 명의 프로필만 바꾸는 요청이 실수로 1,000명을 바꾸면 실패해야 한다”는 안전장치다. Proxy-Status: PostgREST; error=PGRST116 같은 header는 브라우저 Network 탭, Supabase Logs Explorer, gateway 로그에서 오류를 더 빨리 찾게 한다.

하지만 오늘 글이 kid에 집중하는 이유는, 이 변화가 RLS 장애의 원인을 헷갈리게 만들 수 있기 때문이다. RLS 정책이 틀린 것처럼 보여도 실제로는 PostgREST가 JWT를 검증하지 못해 DB 세션에 사용자 claim을 넣지 못한 것일 수 있다.

Supabase changelog도 과거 Custom JWT와 Signing Keys 관련 이슈를 다루며, Data API(PostgREST) v13의 더 엄격한 JWT 검증 때문에 일부 custom JWT 사용자가 문제를 겪었다고 설명한다. 해결 방향은 문서에 맞춰 custom signing key를 다시 가져오거나, 검증 가능한 키 구성을 맞추는 쪽이다.

4. 개발자 관점 해석

웹앱 팀이 여기서 배워야 할 것은 “JWT 디버깅 순서”다. RLS 오류가 났을 때 바로 정책 SQL만 고치기 시작하면 원인을 놓칠 수 있다.

1) 실제 wire에 실린 토큰을 본다

브라우저 DevTools의 Network 탭이나 서버 로그에서 실패한 요청의 Authorization: Bearer USER_ACCESS_TOKEN 값을 확인한다. 자주 나오는 실수는 다음과 같다.

  • apikey header에는 publishable/anon key가 있는데, Authorization에도 사용자 access token 대신 API key가 들어간다.
  • SSR/Route Handler에서 새 Supabase client를 만들면서 세션을 전달하지 않는다.
  • 프록시가 Authorization header를 지운다.
  • 로그아웃/토큰 갱신 타이밍 때문에 만료된 access token을 보낸다.

2) JWT header와 payload를 둘 다 본다

payload에서 볼 것:

  • sub: 사용자 UUID가 있는가
  • role: authenticated인가, anon인가
  • exp: 만료되지 않았는가
  • iss/aud: 기대한 프로젝트와 맞는가

header에서 볼 것:

  • alg: 현재 프로젝트의 signing key 알고리즘과 맞는가
  • kid: JWKS에 존재하는 키 ID인가

특히 custom JWT나 외부 인증 시스템을 붙인 경우, payload만 복사해서 “맞는데요?”라고 판단하면 안 된다. PostgREST가 보는 것은 payload의 모양이 아니라 서명이 현재 신뢰하는 키로 검증되는가다.

3) Data API가 claim을 DB 세션에 넣었는지 확인한다

RLS는 보통 auth.uid()auth.role()에 의존한다. 이 값들은 PostgREST가 검증한 JWT claim을 PostgreSQL request setting으로 전달할 때 의미가 생긴다.

디버깅용으로 이런 RPC를 잠깐 만들 수 있다.

zsh — 생존확인.sh
create or replace function public.whoami()
returns jsonb
language sql
stable
as $$
  select jsonb_build_object(
    'uid', auth.uid(),
    'role', auth.role(),
    'claims', current_setting('request.jwt.claims', true)
  );
$$;

그리고 실패한 요청과 같은 header로 호출한다.

zsh — 생존확인.sh
curl -X POST 'https://<project>.supabase.co/rest/v1/rpc/whoami' \
  -H 'apikey: <publishable-or-anon-key>' \
  -H 'Authorization: Bearer USER_ACCESS_TOKEN' \\
  -H 'Content-Type: application/json' \
  -d '{}'
  • role=authenticated, uid가 사용자 UUID다 → RLS 정책 자체를 봐야 한다.
  • role=anon, claims가 비었다 → 토큰 전달/검증/키 설정을 먼저 봐야 한다.

5. 내 프로젝트에 적용할 체크포인트

Supabase를 쓰는 팀

  • JWT Signing Keys 화면에서 현재 in-use, standby, previously used 키 상태를 확인한다.
  • key rotation 직후 access token 만료 시간보다 충분히 기다린 뒤 이전 키를 revoke한다.
  • Edge Functions, custom backend, webhook consumer가 JWT를 직접 검증한다면 supabase.auth.getClaims() 또는 JWKS 기반 검증으로 바꾼다.
  • publishable/secret API key와 사용자 access token의 역할을 문서에 분리해 둔다.

Custom JWT를 쓰는 팀

  • JWT header의 kid와 imported JWK/JWKS의 kid가 정확히 일치하는지 확인한다.
  • alg와 key type이 맞는지 확인한다. 예: RS256 토큰을 EC key로 검증할 수 없다.
  • staging에서 PostgREST 13 기준으로 401, 403, RLS failure를 재현하는 테스트를 만든다.
  • “payload claim 생성 테스트”와 “Data API live request 테스트”를 분리한다.

RLS 정책을 운영하는 팀

  • 정책 SQL 테스트만 하지 말고, 실제 HTTP 요청으로 auth.uid()가 들어오는지 확인한다.
  • 403이 나면 먼저 세 가지를 나눈다.
    1. 토큰 없음/만료/잘못된 Bearer
    2. 토큰 서명 검증 실패
    3. 토큰은 맞지만 RLS 조건 실패
  • 오류 로그에서 Proxy-Status와 PostgREST error code를 함께 본다.

6. 오늘 10분 액션

  1. 브라우저에서 Supabase REST 요청 하나를 골라 Network 탭을 연다.
  2. apikeyAuthorization header가 서로 다른 역할인지 확인한다.
  3. Bearer token의 header를 decode해서 kidalg를 적는다.
  4. Supabase Dashboard의 JWT Signing Keys 또는 JWKS endpoint에서 같은 kid가 있는지 확인한다.
  5. 위의 whoami() RPC를 staging에 만들고, 실제 앱 요청과 같은 header로 호출해 auth.uid()가 들어오는지 본다.

작은 팀이라면 이 10분 점검만으로 “RLS가 틀렸다”와 “인증 claim이 DB까지 오지 않았다”를 분리할 수 있다.

7. 더 볼 자료

중복 회피 메모

최근 web_app_dev 글은 Self-hosted Supabase Auth URL 라우팅 계약, Hono 서버리스 어댑터 입력 계약, Turbopack 빌드 캐시, Next.js 인증/streaming 경계, Server Actions POST 요청 계약, Supabase OAuth/RLS 위임 권한, Supabase 브랜칭과 RLS 테스트를 다뤘다. 이번 글은 Supabase Auth나 RLS를 다시 언급하지만, OAuth callback 경로·scope 위임·정책 작성법이 아니라 PostgREST 13의 JWT kid 검증과 Supabase Signing Keys를 바탕으로 “토큰이 어떤 키로 검증되는가”를 Data API/RLS 장애 디버깅 계약으로 해석하는 데 집중한다.

오늘 10분 액션+5%

댓글 0

최신순 ▾
한 줄 남기기

혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)