// why it matters
The service_role key bypasses every RLS policy. Shipping it to the browser hands full read/write over your database to anyone who opens DevTools. This is the single most common reason vibe-coded apps get owned in the first week after launch.
// outcome
Full DB compromise → RLS-enforced multi-tenant; key rotated and never exposed again.
✕ BEFOREts
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
// "It didn't work with anon so I switched to service_role" — every founder, ever
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
)
// Called directly from the browser:
await supabase.from('users').delete().neq('id', 0) // 💀✓ AFTERts
// src/integrations/supabase/client.ts (browser)
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY, // anon, RLS applies
)
// src/lib/admin.server.ts (server-only, never imported by routes)
import { createClient } from '@supabase/supabase-js'
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
// Privileged writes go through a verified server function.
export const deleteUser = createServerFn({ method: 'POST' })
.middleware([requireSupabaseAuth])
.inputValidator(z.object({ id: z.string().uuid() }))
.handler(async ({ data, context }) => {
if (!(await hasRole(context.userId, 'admin'))) throw new Error('forbidden')
const { supabaseAdmin } = await import('@/lib/admin.server')
await supabaseAdmin.from('users').delete().eq('id', data.id)
})// notes on the diff
- →Rotate the leaked key in the provider dashboard before anything else.
- →Publishable/anon key is safe in the client — RLS is the gate, not the key.
- →service_role only lives in server-only files, imported dynamically inside handlers.