Supabase Edge Function으로 service_role 키를 안전하게 격리하는 법 — 결제 웹훅, 권한 상승 등 RLS가 커버 못 하는 작업 처리하기
Supabase로 프로젝트를 키우다 보면 반드시 막히는 순간이 있습니다. RLS 정책을 아무리 정교하게 짜도 "이건 어떤 사용자 컨텍스트로도 표현이 안 되는데?"라는 작업이 꼭 나타납니다. 결제 완료 후 주문 상태를 서버에서 바꿔야 하거나, 슈퍼관리자가 다른 유저의 역할을 승격시켜야 하는 경우가 대표적입니다. 이때 service_role 키를 그냥 클라이언트 코드에 박아버리고 싶은 유혹이 스멀스멀 올라옵니다. 저도 처음엔 그 유혹에 넘어갈 뻔했습니다.
이 글에서는 Edge Function을 서버 경계로 삼아 service_role 키를 안전하게 격리하고, 두 개의 Supabase 클라이언트를 목적에 따라 분리해 관리자 전용 작업을 구현하는 패턴을 다룹니다. Stripe 결제 웹훅, 권한 상승, 트랜잭션 내 RLS 선택 적용까지 실제 코드와 함께 살펴볼 예정입니다.
핵심 개념
RLS가 커버하지 못하는 영역이 존재한다
PostgreSQL의 Row Level Security는 auth.uid()를 중심으로 "이 사용자가 이 행에 접근할 수 있나?"를 판단합니다. 여기서 auth.uid()는 현재 요청을 보낸 사용자의 고유 식별자로, RLS 정책이 "내 데이터만 조회"같은 규칙을 구현할 때 기준이 되는 값입니다. 대부분의 CRUD는 이걸로 충분하지만, 몇 가지 패턴에서 RLS는 근본적으로 한계를 드러냅니다.
| 시나리오 | RLS로 처리가 어려운 이유 |
|---|---|
| Stripe 웹훅으로 주문 상태 변경 | 요청 주체가 Stripe 서버이므로 사용자 JWT 없음 |
| 슈퍼관리자가 타 유저 역할 변경 | 변경 대상이 요청자 본인이 아님 |
| 여러 테이블에 걸친 원자적 트랜잭션 | 각 행마다 다른 소유자 컨텍스트가 필요 |
| 시스템 배치 작업 | 특정 사용자 세션이 존재하지 않음 |
이런 경우에 service_role 키가 필요합니다. 이 키는 PostgreSQL 내부적으로 BYPASSRLS 속성을 가진 service_role 역할에 매핑되어, 모든 RLS 정책을 무조건 우회합니다.
BYPASSRLS란? PostgreSQL 역할에 부여할 수 있는 속성으로, 이 속성을 가진 역할은SET row_security = off와 동일하게 동작합니다. RLS 정책 자체를 무력화하므로, 이 권한을 가진 클라이언트는 테이블의 모든 행에 완전히 접근할 수 있습니다.
문제는 이 강력한 키를 어디서 쓰느냐입니다. 브라우저나 모바일 앱에서 직접 사용하면 네트워크 탭 한 번만 열어도 키가 노출됩니다. 여기서 Edge Function이 등장합니다.
Dual Client 패턴 — 두 클라이언트를 목적에 따라 분리하기
Edge Function은 Deno 런타임 위에서 돌아가는 서버 환경입니다. 환경 변수는 외부에 절대 노출되지 않으므로, SUPABASE_SERVICE_ROLE_KEY를 안전하게 보관할 수 있습니다. 핵심 아이디어는 간단합니다. 클라이언트 두 개를 만들되, 역할을 명확히 분리하는 것입니다.
// 목적이 다른 두 클라이언트
const userClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: req.headers.get('Authorization')! }
}
}
)
// ↑ 요청자의 JWT를 그대로 전달 → RLS가 해당 사용자 컨텍스트로 적용됨
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// ↑ service_role → RLS 완전 우회, 관리자 전용 작업에만 사용userClient는 요청자가 누구인지 검증하는 데 씁니다. adminClient는 검증을 통과한 후 실제 관리자 작업을 수행하는 데만 씁니다. 이 순서가 바뀌면 보안 구멍이 생깁니다.
SUPABASE_SERVICE_ROLE_KEY자동 주입: Edge Function 환경에는SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY가 기본으로 주입됩니다. 별도로 Supabase Secrets에 등록하지 않아도 됩니다.
2025년 새 API 키 체계로의 전환
이 패턴을 구현하면서 함께 알아두면 좋을 내용이 있습니다. Supabase는 2025년 6월 Early Access를 시작으로, 기존 JWT 기반 장기 키를 대체하는 새 API 키 시스템을 출시했습니다. 2025년 11월 1일 이후 복원되는 프로젝트는 레거시 키 없이 복원되고, 신규 프로젝트는 anon / service_role 키가 아예 제공되지 않습니다. 즉 6월부터 출시, 11월부터 신규·복원 프로젝트에 의무 적용되는 일정입니다.
| 구분 | 레거시 | 신규 (2025~) |
|---|---|---|
| 저권한 키 | anon (JWT) |
sb_publishable_... |
| 고권한 키 | service_role (JWT) |
sb_secret_... |
| 키 회전 | 수동, 다운타임 위험 | 즉시, 개별 키별로 가능 |
| 감사 로그 | 없음 | 조직 Audit Log에 전 이벤트 기록 |
| 유출 감지 | 없음 | GitHub 공개 저장소 감지 시 자동 폐기 + 이메일 알림 |
현재 레거시 키를 쓰고 있다면 조기 전환을 권장합니다.
실전 적용
예시 1: Stripe 결제 완료 웹훅으로 주문 상태 업데이트
웹훅은 가장 전형적인 케이스입니다. Stripe 서버가 직접 호출하기 때문에 사용자 JWT가 존재하지 않습니다. JWT 검증을 끄는 대신, Stripe 웹훅 서명을 검증해 위조 요청을 막는 방식으로 보안을 유지합니다.
한 가지 실무 팁을 드리자면, 결제 생성 시 Stripe PaymentIntent에 metadata.order_id를 반드시 포함시켜두어야 합니다. 이 값이 없으면 웹훅에서 어떤 주문과 연결해야 할지 알 수 없습니다. Stripe 대시보드에서 엔드포인트 URL을 등록할 때 payment_intent.succeeded 이벤트를 선택해두는 것도 잊지 마세요.
// supabase/functions/stripe-webhook/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import Stripe from 'https://esm.sh/stripe@13'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
Deno.serve(async (req) => {
// 1단계: Stripe 서명 검증 — 이게 없으면 누구나 위조 요청을 보낼 수 있음
const sig = req.headers.get('stripe-signature')!
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
// 2단계: 이벤트 타입 확인 후 service_role로 주문 완료 처리
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
const orderId = paymentIntent.metadata.order_id
// Stripe는 응답이 늦으면 웹훅을 재시도합니다.
// 실무에서는 orders 테이블에 idempotency_key(payment_intent.id)를
// 저장해 중복 처리를 방지하는 것을 권장합니다.
const { error } = await adminClient
.from('orders')
.update({
status: 'paid',
paid_at: new Date().toISOString()
})
.eq('id', orderId)
if (error) {
console.error('Order update failed:', error)
return new Response('Internal error', { status: 500 })
}
}
return new Response('ok', { status: 200 })
})이 함수는 verify_jwt = false로 설정해야 합니다. Stripe는 Supabase JWT를 보내지 않기 때문입니다.
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false| 코드 포인트 | 역할 |
|---|---|
stripe.webhooks.constructEvent |
Stripe 서명 검증 — JWT 대신 이것이 보안 게이트 역할 |
adminClient |
RLS 우회하여 orders 테이블 직접 업데이트 |
metadata.order_id |
결제 생성 시 Stripe PaymentIntent에 포함시켜야 하는 값 |
예시 2: 슈퍼관리자의 사용자 권한 상승
이 케이스에서는 사용자 JWT가 있습니다. 중요한 건 검증 순서입니다. userClient로 요청자가 슈퍼관리자인지 먼저 확인하고, 통과한 후에만 adminClient를 꺼냅니다.
요청 바디는 인증 검사와 무관하게 파싱이 가능하므로, 아래 코드처럼 먼저 읽어두면 흐름이 자연스럽습니다. 그리고 targetUserId와 newRole은 반드시 유효성을 검증해두세요. 허용된 역할 목록 외의 값이 들어오면 거부하는 것이 안전합니다.
// supabase/functions/promote-user/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const ALLOWED_ROLES = ['editor', 'admin', 'superadmin'] as const
type AllowedRole = typeof ALLOWED_ROLES[number]
Deno.serve(async (req) => {
// 요청 바디를 먼저 파싱 (인증 결과와 무관하게 읽을 수 있음)
const { targetUserId, newRole } = await req.json()
if (!targetUserId || !ALLOWED_ROLES.includes(newRole as AllowedRole)) {
return new Response('Invalid request body', { status: 400 })
}
// 1단계: 요청자 JWT로 신원 확인
const userClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: req.headers.get('Authorization') ?? '' }
}
}
)
const { data: { user }, error: authError } = await userClient.auth.getUser()
if (authError || !user) {
return new Response('Unauthorized', { status: 401 })
}
// 2단계: RLS가 적용된 상태로 요청자 프로필 조회
// → 본인 프로필만 볼 수 있는 RLS 정책이 여기서 보호막 역할
const { data: profile } = await userClient
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (profile?.role !== 'superadmin') {
return new Response('Forbidden', { status: 403 })
}
// 3단계: 검증 통과 후 adminClient로 대상 유저 역할 변경
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { error: updateError } = await adminClient.auth.admin.updateUserById(
targetUserId,
{ user_metadata: { role: newRole } }
)
if (updateError) {
return new Response('Update failed', { status: 500 })
}
return new Response(
JSON.stringify({ success: true }),
{ headers: { 'Content-Type': 'application/json' } }
)
})adminClient가 함수 최상단이 아닌 검증 로직 이후에 선언된 것을 눈여겨봐 주세요. 습관적으로 최상단에 두면, 검증 로직을 실수로 건너뛰더라도 adminClient가 이미 활성화된 상태가 됩니다. 의도적으로 검증 이후에 생성하는 패턴이 코드 구조 자체를 안전장치로 만들어줍니다.
참고로
adminClient를 매 요청마다 새로 생성하면 DB 연결을 재사용할 수 없습니다. 요청 빈도가 높은 함수라면 모듈 최상단에서 한 번만 생성하고 검증 후 사용하는 구조도 검토해볼 수 있습니다.
예시 3: 트랜잭션 내에서 RLS를 선택적으로 적용하기 (고급 패턴)
앞의 두 예시는 supabase-js 안에서 완결됩니다. 이번 패턴은 그 경계를 넘어서는 고급 기법입니다. 재고 차감과 주문 생성처럼 원자성이 필요한 경우, supabase-js만으로는 트랜잭션을 세밀하게 제어하기 어렵습니다. 이때 deno-postgres로 DB에 직접 연결하면서도 RLS를 선택적으로 적용하는 패턴이 있습니다. 2025년 12월 marmelab 블로그에서 소개된 방식입니다.
// 버전 태그를 고정하지 않으면 최신 버전에서 API가 달라 오류가 날 수 있습니다
import { Client } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
Deno.serve(async (req) => {
const { userId, productId, quantity } = await req.json()
// SET LOCAL에는 파라미터 바인딩이 직접 적용되지 않으므로
// 외부 입력값인 userId를 SQL에 삽입하기 전 UUID 형식을 반드시 검증합니다
if (!UUID_REGEX.test(userId)) {
return new Response('Invalid userId', { status: 400 })
}
// deno-postgres는 매 요청마다 새 연결을 생성합니다.
// 트래픽이 많다면 PgBouncer 같은 연결 풀러를 앞단에 두는 것을 권장합니다.
const client = new Client(Deno.env.get('DATABASE_URL')!)
await client.connect()
try {
await client.queryArray('BEGIN')
// RLS를 특정 userId 컨텍스트로 적용
// → 해당 트랜잭션 내에서만 이 유저로 동작
await client.queryArray(
`SET LOCAL request.jwt.claims.sub = '${userId}'`
)
await client.queryArray(`SET LOCAL ROLE authenticated`)
// 이후 쿼리는 userId 기준 RLS가 적용된 상태로 실행됩니다
await client.queryArray(
`UPDATE inventory SET stock = stock - $1 WHERE product_id = $2`,
[quantity, productId]
)
await client.queryArray(
`INSERT INTO orders (user_id, product_id, quantity) VALUES ($1, $2, $3)`,
[userId, productId, quantity]
)
await client.queryArray('COMMIT')
return new Response(JSON.stringify({ success: true }))
} catch (e) {
await client.queryArray('ROLLBACK')
return new Response('Transaction failed', { status: 500 })
} finally {
await client.end()
}
})
SET LOCAL이란? PostgreSQL에서 트랜잭션 범위 내에서만 유효한 설정을 적용하는 명령입니다.SET LOCAL ROLE authenticated를 하면 해당 트랜잭션 안에서만authenticated역할로 동작하고, 트랜잭션 종료 후 자동으로 원래 역할로 돌아옵니다. 여기서authenticated는 Supabase에서 로그인한 일반 사용자에게 부여되는 PostgreSQL 역할 이름입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 서버 사이드 격리 | service_role 키가 Edge Function 환경 변수에만 존재하며 클라이언트에 절대 노출되지 않음 |
| 유연한 권한 제어 | RLS로 표현 불가한 크로스-유저, 시스템 레벨 작업을 명확히 처리 가능 |
| 외부 서비스 통합 | Stripe, Slack 등 서드파티 웹훅이 사용자 JWT 없이도 자연스럽게 연동됨 |
| 환경 변수 자동 주입 | SUPABASE_SERVICE_ROLE_KEY는 별도 등록 없이 Edge Function에 기본 제공 |
| 감사 가능성 | 신규 API 키 체계에서 모든 접근 이벤트가 Audit Log에 자동 기록됨 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 단일 실패 지점 | service_role 키 유출 시 전체 DB가 위험에 노출 |
신규 sb_secret_... 키로 전환 후 개별 회전 기능 활용. Supabase 대시보드 → Settings → API Keys에서 즉시 회전 가능 |
| 과도한 권한 | 필요 이상의 테이블에 접근 가능 — 최소 권한 원칙 위반 가능성 | 가능하면 특정 테이블에만 접근 가능한 전용 PostgreSQL 역할을 별도 생성하고, adminClient가 수행하는 작업 범위를 코드로 좁혀두는 것이 좋음 |
| 감사 추적 복잡 | 관리자 작업에 사용자 컨텍스트가 없어 누가 유발했는지 추적 어려움 | 함수 내부에서 triggered_by 컬럼에 요청자 userId를 명시적으로 기록 |
| 키 교체 복잡성 | 레거시 시스템에서 키 회전 시 다운타임 위험 | 신규 sb_secret_... 키는 개별 회전 지원으로 이 문제가 해결됨 |
최소 권한 원칙(Principle of Least Privilege): 시스템 컴포넌트에 필요한 최소한의 권한만 부여해야 한다는 보안 설계 원칙입니다.
service_role키는 이 원칙과 충돌하므로, 사용 범위를 코드 레벨에서 최대한 좁혀두는 것을 권장합니다.
실무에서 가장 흔한 실수
adminClient를 함수 최상단에서 무조건 생성하기 — 검증 로직보다 먼저 생성해두면, 인증 단계를 실수로 스킵했을 때 관리자 클라이언트가 그대로 동작합니다. 검증 통과 후에 생성하는 패턴이 훨씬 안전합니다.- 웹훅 함수에서
verify_jwt = false만 설정하고 서명 검증을 생략하기 — JWT 검증을 끄는 순간 누구나 해당 엔드포인트를 호출할 수 있습니다. Stripe 서명 검증처럼 서드파티 웹훅 고유의 인증 방식을 반드시 구현해야 합니다. service_role키를 환경 변수가 아닌 코드에 직접 하드코딩하기 — 개발 편의상 잠깐 넣었다가 그대로 커밋되는 경우가 생각보다 많습니다. 신규 API 키 체계의 GitHub 자동 감지 기능이 이를 잡아주지만, 그 전에.env파일과.gitignore설정을 철저히 관리해두는 것이 좋습니다.
마치며
Edge Function을 서버 경계로 삼아 두 개의 클라이언트를 명확히 분리하면, service_role 키의 강력한 권한을 안전하게 격리하면서도 RLS 경계 바깥의 관리자 작업을 깔끔하게 처리할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
- 기존 프로젝트에서
service_role키 사용 위치를 점검해보세요.grep -r "service_role" src/또는grep -r "SUPABASE_SERVICE_ROLE_KEY" .를 실행해 클라이언트 코드에 포함된 것이 없는지 확인해보시면 좋습니다. supabase functions new admin-action명령어로 첫 번째 Edge Function을 생성하고, 위에서 다룬 Dual Client 패턴을 적용해 기존 API Route의 관리자 로직을 마이그레이션해보세요.- Supabase 대시보드의 Settings → API Keys에서 신규
sb_secret_...키 체계를 확인하세요. 레거시service_roleJWT를 사용 중이라면 마이그레이션 일정을 잡아두는 것이 좋습니다. 2025년 11월 이후 새로 복원하는 프로젝트에는 레거시 키가 제공되지 않습니다.
이번에 다룬 RLS 우회 패턴을 더 세밀하게 제어하고 싶다면, PostgreSQL의 SECURITY DEFINER 함수와 조합하는 방법도 있습니다. Edge Function 없이 DB 레벨에서 권한 경계를 그을 수 있어, 복잡한 비즈니스 로직을 다루는 경우 더 강력한 선택지가 됩니다.
참고 자료
- Edge Functions 개요 | Supabase 공식 문서
- Edge Functions 보안(JWT 설정) | Supabase 공식 문서
- Row Level Security | Supabase 공식 문서
- API Keys 이해 | Supabase 공식 문서
- Stripe 웹훅 처리 예시 | Supabase 공식 문서
- Service Role 키 RLS 트러블슈팅 | Supabase 공식 문서
- Security Retro 2025 | Supabase 블로그
- JWT Signing Keys 도입 | Supabase 블로그
- Upcoming changes to Supabase API Keys | Supabase Changelog
- Transactions and RLS in Supabase Edge Functions | marmelab
- Supabase RLS Best Practices | makerkit
- Edge Functions: authorize user AND bypass RLS | GitHub Discussions