How to Safely Isolate the service_role Key with Supabase Edge Functions — Handling Tasks RLS Can't Cover: Payment Webhooks, Privilege Escalation, and More
As you scale a project with Supabase, there's a moment you'll inevitably hit. No matter how carefully you craft your RLS policies, certain tasks always emerge that simply can't be expressed in any user context: updating order status server-side after a payment completes, or a super admin promoting another user's role. At that point, the temptation to just hardcode the service_role key directly into your client code starts creeping in. I almost gave in to that temptation myself.
This article covers a pattern where Edge Functions serve as the server boundary to safely isolate the service_role key, managing two separate Supabase clients by purpose to implement admin-only operations. We'll walk through real code examples covering Stripe payment webhooks, privilege escalation, and selective RLS application within transactions.
Core Concepts
There Are Areas RLS Cannot Cover
PostgreSQL's Row Level Security revolves around auth.uid() to determine "can this user access this row?" Here, auth.uid() is the unique identifier of the user making the current request — the reference point RLS policies use to implement rules like "view only my own data." This is sufficient for most CRUD operations, but RLS hits fundamental limits in several patterns.
| Scenario | Why RLS Struggles |
|---|---|
| Updating order status via Stripe webhook | The requester is the Stripe server — no user JWT |
| Super admin changing another user's role | The target of the change is not the requester |
| Atomic transactions spanning multiple tables | Each row requires a different owner context |
| System batch jobs | No specific user session exists |
These are the cases that require the service_role key. Internally in PostgreSQL, this key maps to the service_role role with the BYPASSRLS attribute, unconditionally bypassing all RLS policies.
What is
BYPASSRLS? An attribute that can be granted to a PostgreSQL role. A role with this attribute behaves identically toSET row_security = off. Because it completely disables RLS policies, a client holding this privilege has full access to every row in every table.
The problem is where you use this powerful key. If used directly in a browser or mobile app, the key is exposed the moment anyone opens the network tab. This is where Edge Functions come in.
The Dual Client Pattern — Separating Two Clients by Purpose
An Edge Function is a server environment running on the Deno runtime. Environment variables are never exposed externally, so SUPABASE_SERVICE_ROLE_KEY can be stored safely. The core idea is simple: create two clients with clearly separated responsibilities.
// Two clients with different purposes
const userClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: req.headers.get('Authorization')! }
}
}
)
// ↑ Passes the requester's JWT as-is → RLS is applied in that user's context
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// ↑ service_role → full RLS bypass, used only for admin operationsuserClient is used to verify who the requester is. adminClient is used solely to perform the actual admin operation after verification has passed. Reversing this order creates a security hole.
Automatic injection of
SUPABASE_SERVICE_ROLE_KEY: The Edge Function environment hasSUPABASE_URL,SUPABASE_ANON_KEY, andSUPABASE_SERVICE_ROLE_KEYinjected by default. You don't need to register them separately in Supabase Secrets.
Transitioning to the New 2025 API Key System
While implementing this pattern, there's something worth knowing alongside it. Starting with an Early Access launch in June 2025, Supabase introduced a new API key system to replace the existing JWT-based long-lived keys. Projects restored after November 1, 2025 will be restored without legacy keys, and new projects will no longer be provisioned with anon / service_role keys at all. In short: launching in June, mandatory for new and restored projects from November onward.
| Type | Legacy | New (2025~) |
|---|---|---|
| Low-privilege key | anon (JWT) |
sb_publishable_... |
| High-privilege key | service_role (JWT) |
sb_secret_... |
| Key rotation | Manual, risk of downtime | Instant, per individual key |
| Audit log | None | All events recorded in Organization Audit Log |
| Leak detection | None | Auto-revocation + email alert on GitHub public repo detection |
If you're currently using legacy keys, early migration is recommended.
Practical Application
Example 1: Updating Order Status via Stripe Payment Completed Webhook
Webhooks are the most classic case. Because Stripe's servers make the call directly, no user JWT exists. Instead of disabling JWT verification, security is maintained by verifying the Stripe webhook signature to block forged requests.
One practical tip: when creating a payment, always include metadata.order_id in the Stripe PaymentIntent. Without it, the webhook has no way to know which order to link the payment to. Also don't forget to select the payment_intent.succeeded event when registering the endpoint URL in the Stripe dashboard.
// 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) => {
// Step 1: Verify Stripe signature — without this, anyone can send forged requests
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 })
}
// Step 2: Check event type, then process order completion with service_role
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
const orderId = paymentIntent.metadata.order_id
// Stripe retries webhooks if your response is slow.
// In production, it's recommended to store an idempotency_key (payment_intent.id)
// in the orders table to prevent duplicate processing.
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 })
})This function must be configured with verify_jwt = false, because Stripe does not send a Supabase JWT.
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false| Code Point | Role |
|---|---|
stripe.webhooks.constructEvent |
Stripe signature verification — acts as the security gate instead of JWT |
adminClient |
Bypasses RLS to directly update the orders table |
metadata.order_id |
Value that must be included in the Stripe PaymentIntent when creating a payment |
Example 2: Super Admin Privilege Escalation
In this case, a user JWT is present. What matters is the order of verification. Use userClient to confirm the requester is a super admin first, and only then bring out adminClient.
The request body can be parsed regardless of the authentication check, so reading it upfront as shown below keeps the flow natural. Make sure to validate targetUserId and newRole — it's safest to reject any values outside the permitted roles list.
// 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) => {
// Parse request body first (can be read regardless of auth result)
const { targetUserId, newRole } = await req.json()
if (!targetUserId || !ALLOWED_ROLES.includes(newRole as AllowedRole)) {
return new Response('Invalid request body', { status: 400 })
}
// Step 1: Verify identity with requester's 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 })
}
// Step 2: Query requester's profile with RLS applied
// → The RLS policy that allows viewing only your own profile acts as a guard here
const { data: profile } = await userClient
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (profile?.role !== 'superadmin') {
return new Response('Forbidden', { status: 403 })
}
// Step 3: After verification passes, use adminClient to change the target user's role
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' } }
)
})Notice that adminClient is declared after the verification logic, not at the top of the function. If you habitually put it at the top, adminClient is already active even if you accidentally skip the verification logic. Deliberately creating it after verification makes the code structure itself a safety mechanism.
Note that creating
adminClientfresh on every request prevents DB connection reuse. For functions with high request volume, you might consider creating it once at the module's top level and using it only after verification passes.
Example 3: Selectively Applying RLS Within a Transaction (Advanced Pattern)
The previous two examples are self-contained within supabase-js. This pattern goes beyond that boundary. For operations requiring atomicity — like decrementing inventory and creating an order — fine-grained transaction control is hard to achieve with supabase-js alone. Here's a pattern that connects directly to the DB via deno-postgres while still selectively applying RLS. This approach was introduced on the marmelab blog in December 2025.
// Not pinning the version tag can cause errors if the API changes in a newer version
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()
// Parameter binding doesn't apply directly to SET LOCAL,
// so always validate userId as a UUID before interpolating it into SQL
if (!UUID_REGEX.test(userId)) {
return new Response('Invalid userId', { status: 400 })
}
// deno-postgres creates a new connection per request.
// For high-traffic functions, consider putting a connection pooler like PgBouncer in front.
const client = new Client(Deno.env.get('DATABASE_URL')!)
await client.connect()
try {
await client.queryArray('BEGIN')
// Apply RLS in a specific userId context
// → operates as this user only within this transaction
await client.queryArray(
`SET LOCAL request.jwt.claims.sub = '${userId}'`
)
await client.queryArray(`SET LOCAL ROLE authenticated`)
// Subsequent queries run with RLS applied based on userId
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()
}
})What is
SET LOCAL? A PostgreSQL command that applies a setting only for the duration of the current transaction.SET LOCAL ROLE authenticatedcauses the transaction to operate as theauthenticatedrole for its duration, then automatically reverts to the original role after the transaction ends. Here,authenticatedis the name of the PostgreSQL role Supabase assigns to logged-in regular users.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Server-side isolation | The service_role key exists only in Edge Function environment variables and is never exposed to clients |
| Flexible access control | Cross-user and system-level operations that can't be expressed in RLS are handled cleanly |
| External service integration | Third-party webhooks like Stripe and Slack integrate naturally without a user JWT |
| Automatic environment variable injection | SUPABASE_SERVICE_ROLE_KEY is available in Edge Functions by default without separate registration |
| Auditability | With the new API key system, all access events are automatically recorded in the Audit Log |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Single point of failure | If the service_role key leaks, the entire DB is at risk |
Migrate to the new sb_secret_... key and use per-key rotation. Rotate instantly from Supabase Dashboard → Settings → API Keys |
| Excessive privileges | Can access more tables than necessary — potential violation of least privilege | Where possible, create a dedicated PostgreSQL role with access limited to specific tables, and narrow the scope of adminClient operations in code |
| Complex audit trail | Admin operations lack user context, making it hard to trace who triggered them | Explicitly record the requester's userId in a triggered_by column within the function |
| Key rotation complexity | Risk of downtime when rotating keys in legacy systems | The new sb_secret_... keys support individual rotation, resolving this issue |
Principle of Least Privilege: A security design principle stating that each system component should be granted only the minimum permissions necessary. The
service_rolekey conflicts with this principle, so it's recommended to narrow its scope of use as much as possible at the code level.
The Most Common Mistakes in Production
- Always creating
adminClientunconditionally at the top of the function — If it's created before the verification logic, the admin client operates normally even if you accidentally skip the authentication step. Creating it only after verification passes is far safer. - Setting
verify_jwt = falsein webhook functions without implementing signature verification — The moment you disable JWT verification, anyone can call that endpoint. You must implement the third-party webhook's own authentication method, such as Stripe signature verification. - Hardcoding the
service_rolekey directly in code instead of an environment variable — More often than you'd think, keys added temporarily for development convenience get committed as-is. The new API key system's GitHub auto-detection will catch this, but it's better to manage.envfiles and.gitignoresettings rigorously before it comes to that.
Closing Thoughts
By using Edge Functions as the server boundary and clearly separating two clients, you can safely isolate the powerful privileges of the service_role key while handling admin operations outside the RLS boundary cleanly.
Three steps you can start right now:
- Audit where the
service_rolekey is used in your existing project. Rungrep -r "service_role" src/orgrep -r "SUPABASE_SERVICE_ROLE_KEY" .to check whether it's included anywhere in client-side code. - Create your first Edge Function with
supabase functions new admin-actionand apply the Dual Client pattern covered above to migrate admin logic from existing API routes. - Check the new
sb_secret_...key system in Supabase Dashboard → Settings → API Keys. If you're still using the legacyservice_roleJWT, it's worth scheduling a migration. Projects newly restored after November 2025 will not be provided with legacy keys.
If you want even finer control over the RLS bypass pattern covered here, there's also an approach combining it with PostgreSQL's SECURITY DEFINER functions. It lets you draw permission boundaries at the DB level without an Edge Function, making it a more powerful option for complex business logic.
References
- Edge Functions Overview | Supabase Docs
- Edge Functions Security (JWT Configuration) | Supabase Docs
- Row Level Security | Supabase Docs
- Understanding API Keys | Supabase Docs
- Stripe Webhook Handling Example | Supabase Docs
- Service Role Key RLS Troubleshooting | Supabase Docs
- Security Retro 2025 | Supabase Blog
- Introducing JWT Signing Keys | Supabase Blog
- 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