8 min read

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

Securie findingcritical
supabase/migrations/0042_orders_rls.sql:14

The 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.

Suggested fix — ready as a PR
// 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 } },
);
Catch this in my repo →Securie scans every PR · ships the fix as a one-click merge · free during early access

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