The Supabase service-role key — when to use it, when not to, and how it leaks
The service-role key bypasses every RLS policy you wrote. It exists for a reason; it leaks for many reasons. Here is the rule for when to use it, the patterns that leak it, and the recovery playbook when it does.
There are two Supabase keys: the anon key (safe for client) and the service-role key (god-mode, must never reach the client). Half the AI-generated Supabase apps we audit use the service-role key in places it should not be. Here is the rule, the canonical leak patterns, and the recovery flow.
What it is
Supabase issues two API keys per project: the `anon` (anonymous) key, intended for use in browser code and gated by RLS policies; and the `service_role` key, which bypasses RLS entirely and has the same database privileges as the postgres superuser. The anon key is meant to ship in your client bundle. The service-role key is meant to live only on the server and never touch any code path reachable from the browser.
Vulnerable example
// lib/supabase.ts — WRONG
import { createClient } from "@supabase/supabase-js";
// Bug: uses service-role key, file is imported by client components.
// The service-role key ships in the client bundle. Game over.
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // <-- in client bundle
);Fixed example
// lib/supabase-client.ts — for client components
import { createClient } from "@supabase/supabase-js";
export const supabaseClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // anon key only
);
// lib/supabase-admin.ts — server-side ONLY
import "server-only"; // <-- throws if imported by client
import { createClient } from "@supabase/supabase-js";
// Service-role client for admin operations that legitimately need
// to bypass RLS (e.g. webhook handlers, scheduled jobs, internal
// admin actions). Never imported by client components.
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!, // no NEXT_PUBLIC_ prefix
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } },
);How Securie catches it
supabase/migrations/0042_orders_rls.sql:14The Supabase service-role key
Securie's secret_scanner specialist (Day-1, production-validated) detects Supabase JWT patterns (`eyJ...`) and decodes the role claim. If the role is `service_role` and the file is reachable from a client bundle (Next.js page, component, or library imported by either), the finding ships with `Live key confirmed` (validated against the Supabase project) and an auto-rotation PR (Indie tier and up) that resets the service-role key in the dashboard, updates server-side env vars, and revokes the leaked key.
// lib/supabase-client.ts — for client components
import { createClient } from "@supabase/supabase-js";
export const supabaseClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // anon key only
);
// lib/supabase-admin.ts — server-side ONLY
import "server-only"; // <-- throws if imported by client
import { createClient } from "@supabase/supabase-js";
// Service-role client for admin operations that legitimately need
// to bypass RLS (e.g. webhook handlers, scheduled jobs, internal
// admin actions). Never imported by client components.
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!, // no NEXT_PUBLIC_ prefix
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } },
);Checklist
- Anon key is the ONLY Supabase key in any file that runs in the browser.
- Service-role key is wrapped behind `import "server-only"` to enforce server-only import at compile time.
- Service-role key environment variable does NOT have the `NEXT_PUBLIC_` prefix.
- Service-role key is never logged, never put in URL params, never sent in error messages.
- Service-role key is rotated immediately if it ever appears in a commit, even briefly (history rewrites do not help — once public, the key is in scrapers' pools).
- Server-side code that uses service-role still validates the request before performing privileged operations — service-role is the database key, not an authorization grant.
- Webhook handlers that use service-role verify the webhook signature first.
FAQ
When is it OK to use the service-role key?
Three legitimate cases: (1) webhook handlers where the request originator is verified by signature, not by user JWT; (2) scheduled jobs / cron tasks running on your server, no user context; (3) admin operations where you have already authenticated the user as an admin via your own auth flow. In all three, the service-role usage is server-side only.
I committed the service-role key to git two months ago. Is it compromised?
Assume yes. Once a Supabase service-role key has been in a public commit (even a private repo's commit history is at risk if any historical contributor has been compromised), it is in scraper pools. Rotate immediately: Supabase dashboard → Settings → API → reset service_role. Update your server-side env vars. Audit your database for unexpected writes during the exposure window.
How does service-role compare to RLS bypass via anon-role grants?
Service-role bypasses RLS entirely (god-mode). RLS bypass via overly-broad anon grants (`grant select on table to anon`) gives the anon role specific privileges that RLS would otherwise filter. Both are RLS bypasses; service-role is the broader / more dangerous; anon-role grants are the more common AI-generated bug. Audit both.
What's the difference between `import "server-only"` and just naming the file `server.ts`?
`import "server-only"` is a compile-time enforcement — Next.js bundler errors at build time if a client component imports the file. Naming convention alone does not prevent accidental client imports. Always use `import "server-only"` for files that touch service-role or other server-only secrets.
Related guides
Row-Level-Security bypass is the most common data leak in vibe-coded apps. Here is exactly how it happens, how attackers find it, and how to fix it in Next.js + Supabase with one policy update.
Vercel environment variables have three flavors (development, preview, production) and two scopes (server-only and NEXT_PUBLIC_). Mixing them up leaks production secrets. Here is the rule and the canonical bugs.
Every week founders tweet about their OpenAI bill going from $10 to $10,000 overnight. Usually the cause is an API key committed to a public repo. Here is why it happens in Next.js specifically and how to stop it in five minutes.
Not in .env files. Not in localStorage. Here is the 2026 guide to storing and accessing secrets in a small-team Node.js / Python app.