SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Exposed Supabase service_role Key
CWE-522Critical severitySupabaseNext.js

Exposed Supabase service_role Key

◐SecureStartKit isolates the service_role key behind a server-only createAdminClient() helper and never references it from client code, but it cannot stop a developer from adding a NEXT_PUBLIC_ prefix or hardcoding the key.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

The Supabase service_role key bypasses all Row Level Security policies. It must never appear in NEXT_PUBLIC_ variables, client components, or version control. Use it only on the server via createAdminClient(), and rotate it immediately if it leaks.

Where it shows up: The service_role key appears in browser DevTools under the Network tab or Sources panel, is readable in your built JavaScript bundle, is committed to a git repository, or is returned inside an API response body.

The vulnerable patterns and their fixes

service_role key sent to the browser via NEXT_PUBLIC_ prefix

✗Vulnerablets
// lib/supabase/client.ts  (bundled into the browser)
import { createClient } from '@supabase/supabase-js'

// NEXT_PUBLIC_ prefix inlines this value into the JS bundle
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // never do this
)

export default supabase

Any environment variable prefixed with NEXT_PUBLIC_ is statically replaced at build time and embedded in the JavaScript bundle served to browsers. Every visitor can read the service_role key from the bundle, bypassing all RLS policies.

↓the fix
✓Securets
// lib/supabase/server.ts  (add 'server-only' so a client import fails the build)
import 'server-only'
import { createClient } from '@supabase/supabase-js'

// No NEXT_PUBLIC_ prefix: Next.js never sends this to the browser
export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!, // server env only
    { auth: { persistSession: false } }
  )
}

Removing the NEXT_PUBLIC_ prefix keeps the variable server-side only. The server-only import causes a build error if this module is ever imported inside a Client Component, making the protection structural rather than relying on developer discipline.

service_role key hardcoded and committed to the repository

✗Vulnerablets
// actions/admin.ts
import { createClient } from '@supabase/supabase-js'

// Hardcoded key: visible in git history forever after commit
const admin = createClient(
  'https://xyzcompany.supabase.co',
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.PLACEHOLDER.FAKE_SIGNATURE'
)

export async function deleteUser(id: string) {
  return admin.from('users').delete().eq('id', id)
}

A hardcoded credential is captured permanently in git history. Removing the line in a later commit does not expunge it: the key remains readable via git log or git show on any previous commit, including in clones and forks.

↓the fix
✓Securets
// actions/admin.ts
import 'server-only'
import { createAdminClient } from '@/lib/supabase/server'
import { z } from 'zod'

const schema = z.object({ id: z.string().uuid() })

export async function deleteUser(rawId: string) {
  const { id } = schema.parse({ id: rawId })
  const admin = createAdminClient() // key read from env at runtime
  const { error } = await admin.from('users').delete().eq('id', id)
  if (error) throw new Error('Delete failed')
}

The key is never written in source. createAdminClient() reads SUPABASE_SERVICE_ROLE_KEY from the server environment at runtime. A git-ignored .env.local supplies the value locally, and the hosting platform supplies it in production through its secrets UI.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

An attacker who obtains the service_role key gains unrestricted read and write access to every row in your Supabase database, ignoring all Row Level Security policies. They do not need a user account, a valid JWT, or any other credential.

The two most common leak paths in Next.js applications are both silent: the developer either prefixes the variable with NEXT_PUBLIC_ (which causes Next.js to inline the value into the client-side bundle at build time) or imports it directly inside a Client Component file. In both cases the key ships to every visitor's browser. Chrome DevTools, a bundle explorer, or a simple curl of the built JS chunks are enough to extract it.

Once the key is in hand, the attacker calls the Supabase REST API directly with the Authorization header set to Bearer and the service_role key. Every RLS policy is bypassed at the PostgREST layer before the query even reaches Postgres. The attacker can enumerate users, read private data, overwrite or delete records, and in some configurations execute arbitrary SQL through the rpc endpoint. Because the key is long-lived and does not expire automatically, the exposure continues until the key is rotated in the Supabase dashboard.

Hardcoded keys committed to git create a second leak path that persists in repository history even after the offending line is removed. Public repositories are scanned continuously by automated tools. Private repositories leak via accidental visibility changes, forks, or compromised team-member accounts.

How to find it in your code

Run a recursive grep across your entire repository, including build artifacts if they are checked in. Searching for service_role surfaces any occurrence in source, .env files, or bundled output. Also search for the key value itself if you already know it.

Check your Next.js environment variables. Any variable that holds the service_role key must not start with NEXT_PUBLIC_. Audit your .env, .env.local, .env.production, and any CI/CD pipeline secrets for misnamed variables.

Inspect the built JavaScript bundle. After running next build, look inside .next/static/chunks for the key string. If it appears there, it is already public.

Review your Supabase project settings under Project Settings, then API. If you are uncertain whether the key has been exposed, rotate it immediately from that page. Rotation invalidates the old key within seconds.

Enable git-secrets or a similar pre-commit hook to block future commits that contain credentials. The prefix eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 matches Supabase JWTs and makes a reliable hook pattern.

Common mistakes

  • Myth“I only use it in a Server Action, so naming it NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY for consistency is fine.”

    The NEXT_PUBLIC_ prefix is evaluated at build time, not runtime. Next.js inlines the value into the client bundle regardless of where you use it in code. The name alone causes the leak.

  • Myth“My repository is private, so committing the key is acceptable.”

    Private repositories can become public accidentally, be forked, be cloned by a future contractor, or be exposed through a compromised account. Git history is permanent: a secret committed even once must be treated as compromised and rotated.

  • Myth“Row Level Security protects the database even if the service_role key leaks.”

    The service_role key is designed to bypass RLS. That is its entire purpose: server-side code that needs unrestricted access. An attacker with this key ignores every RLS policy you have written.

  • Myth“Rotating the key is disruptive and can wait until the next deployment.”

    The old key keeps working until it is rotated. Every minute of delay is continued exposure. Supabase rotates keys instantly from the dashboard: update the server environment variable and redeploy.

Does SecureStartKit prevent this?

The kit isolates the service_role key in createAdminClient() (lib/supabase/server.ts), which reads SUPABASE_SERVICE_ROLE_KEY from server-side env with no NEXT_PUBLIC_ prefix and is used only by Server Actions and route handlers. The anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY) is intentionally public and is the only Supabase client used in browser code. Adding a server-only import to that module is a worthwhile hardening step that turns an accidental client-side import into a build error. What the kit cannot enforce is a developer bypassing the pattern by adding a NEXT_PUBLIC_ variable or pasting the key into source.

How the kit isolates the service_role key→

Frequently asked questions

What is the difference between the anon key and the service_role key?
The anon key is a public credential intended for client-side use. It operates under Row Level Security: users can only access rows their RLS policies permit. The service_role key bypasses all RLS policies and has unrestricted access to every row and table. The anon key is safe to expose; the service_role key must never leave the server.
How do I rotate the service_role key after a confirmed or suspected leak?
Open your Supabase project, go to Project Settings, then API, and rotate the service_role key. The old key is invalidated immediately. Update your hosting platform environment variables with the new key and redeploy. Also revoke any active sessions that may have been issued using the leaked key.
Does git remove a secret if I delete the file and commit again?
No. Git stores every version of every file. The secret remains accessible through git log and git show on older commits. To purge it you must rewrite history with git filter-repo or BFG, then force-push and have every collaborator re-clone. The correct response is rotation first, then history cleanup at your own pace.
Can I use the service_role key inside a Next.js route handler?
Yes, provided the file is a server-side route under app/api or pages/api. Use createAdminClient() rather than constructing a client inline, keep SUPABASE_SERVICE_ROLE_KEY without the NEXT_PUBLIC_ prefix, and never return the client or its configuration to the caller.

References

  • CWE-522: Insufficiently Protected Credentials ↗
  • Supabase Docs: API Keys and the service_role key ↗

Related weaknesses

  • Missing or Disabled RLS PolicyA table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.
  • Next.js Middleware Auth BypassAuthorization is implemented only in middleware.ts or proxy.ts, with no second check at the data layer or inside the Server Action, route handler, or page component.
  • IDOR: Missing Ownership CheckA Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.

Defined terms

  • service_role key
  • anon key
  • Backend-Only Data Access

Go deeper

  • Exposed API Keys: How AI Tools Leak Your Secrets
  • SaaS Security Checklist

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns