← All posts
·4 min read·GuardLayer

Your Supabase service role key is one NEXT_PUBLIC_ away from a full database breach

SupabaseRLSSecretsNext.js

Supabase gives every project two keys, and the difference between them is the difference between a normal Tuesday and a breach disclosure email. The anon key is meant to be public — it's gated by your Row Level Security (RLS) policies. The service_role key is the opposite: it bypasses RLS entirely. With it, anyone can read and write every row in every table, regardless of who's asking.

So the single most expensive mistake you can make on Supabase is letting the service_role key reach the browser. And the most common way it happens isn't dramatic — it's a four-character prefix: NEXT_PUBLIC_.

anon key vs. service_role key

anon keyservice_role key
Meant to be public?YesNo — server only
Respects RLS?YesNo — bypasses all policies
Safe in the browser bundle?YesNever
Blast radius if leakedLimited by your policiesYour entire database

The anon key being public is by design. The service_role key being public is a full compromise: read every user's data, delete tables, forge records — no policy stands in the way.

How it actually leaks

In a Next.js app there are three common paths, and all three are easy to do by accident:

  1. The NEXT_PUBLIC_ prefix. Anything named NEXT_PUBLIC_* is inlined into the client JavaScript bundle at build time and shipped to every visitor. Naming the variable NEXT_PUBLIC_SERVICE_ROLE_KEY — often copy-pasted from a tutorial, or added "just to make it work" — publishes it permanently.
  2. Importing a server client into a client component. A lib/supabaseAdmin.ts that reads the service role key gets bundled the moment a "use client" component imports it.
  3. Hardcoding the key. Pasting the literal eyJ… token into source. It's now in git history forever, even if you delete it next commit.

Here's the canonical version of mistake #1 and #2 combined:

"use client";
import { createClient } from "@supabase/supabase-js";

// A client component should NEVER hold the service role key.
export const adminClient = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY!
);

This compiles. It works in development. It passes your tests. And it ships your master key to every browser that loads the page. Here's what GuardLayer reports on exactly that file — this is live engine output, not a mockup:

guardlayer scan · lib/supabaseAdmin.tsLive engine output
Merge blocked
25/100 · F
  • Criticallib/supabaseAdmin.ts:7

    Service role key exposed to the client

    Never prefix the service role key with NEXT_PUBLIC_. Read it only in server code via process.env.SUPABASE_SERVICE_ROLE_KEY, and rotate the key immediately since it has been exposed.
  • Criticallib/supabaseAdmin.ts:7

    Service role key used in client-side code

    Move all service-role usage into a server context (Route Handler, Server Action, or server-only module). On the client use only the anon key, protected by RLS.
  • Criticallib/supabaseAdmin.ts:7

    Secret exposed through NEXT_PUBLIC_

    Drop the NEXT_PUBLIC_ prefix and read the value only on the server. Publishable/anon keys are fine to expose; secret keys, tokens, and passwords are not — rotate any that have shipped.

Why it's catastrophic, not just "bad practice"

Three things compound:

  • It defeats your entire security model. You can write perfect RLS policies, and the service_role key ignores all of them. Your defense-in-depth collapses to zero.
  • It's permanent. Once the key is in a shipped bundle or a public commit, assume it's captured. Bots scrape public repos and JS bundles for exactly this token, continuously.
  • It's silent. Nothing breaks. There's no error, no warning at deploy. You find out when someone else does.

The fix

1. Never prefix it with NEXT_PUBLIC_. Read it only on the server:

import "server-only";
import { createClient } from "@supabase/supabase-js";

// server-only.ts throws if this module is ever imported in client code.
export const adminClient = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // no NEXT_PUBLIC_ prefix
);

The server-only package turns an accidental client import into a build-time error — a guardrail worth adding to any module that touches secrets.

2. On the client, use the anon key and lean on RLS. The browser should only ever hold NEXT_PUBLIC_SUPABASE_ANON_KEY, with policies scoped by auth.uid().

3. If it already shipped, rotate immediately. In the Supabase dashboard, regenerate the service_role key — the exposed one must be considered burned. Then purge it from git history (git filter-repo or BFG); deleting the file is not enough.

How to check whether you're exposed right now

A 30-second audit:

# 1. Any service role key behind a public prefix?
grep -rn "NEXT_PUBLIC_.*SERVICE_ROLE" .

# 2. Any service-role usage inside a client component?
grep -rln '"use client"' | xargs grep -l "SERVICE_ROLE_KEY"

# 3. Search your built bundle for the token shape
grep -r "service_role" .next/ 2>/dev/null

If any of those return a hit, you have work to do. Or scan the whole repo at once — GuardLayer flags this (and 19 other Next.js + Supabase issues) on every push, and gives you the fix inline.

FAQ

Is it safe to expose the Supabase anon key? Yes. The anon key is designed to be public and is constrained by your RLS policies. The service_role key is not — it bypasses RLS and must stay server-side.

I only use the service_role key in an API route. Am I safe? Usually yes — API routes run on the server. The danger is the NEXT_PUBLIC_ prefix and accidental imports into client components. Verify the variable name has no NEXT_PUBLIC_ prefix and that no "use client" file imports the module.

Does deleting the key from the latest commit fix a past leak? No. It remains in git history and in any deployed bundle. Rotate the key and rewrite history.

What's the difference between this and the anon key being in my bundle? The anon key in the bundle is expected and safe (RLS protects you). The service_role key in the bundle is a full database compromise.

Scan your repo for this — free

GuardLayer checks every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.