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.
// 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 supabaseAny 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.
// 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.
// 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.
// 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.
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.
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.
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.
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