How to Avoid: Supabase service-role key client-side
August 3, 2026 · 6-minute read · Fairy
The short answer
To prevent Supabase service-role key exposure in AI-generated code, never use NEXT_PUBLIC_ prefix for service-role keys, keep the service-role client exclusively in server-side files (API routes, server components, edge functions), and use only the anon key for client-side Supabase operations. The service-role key bypasses all Row Level Security, granting full database access to anyone who extracts it from bundled JavaScript.
Why the Supabase Service-Role Key Should Never Touch Client Code
The Supabase service-role key bypasses all Row Level Security (RLS) policies. When this key appears in client-side code—whether through a NEXT_PUBLIC_ environment variable or direct import—anyone can extract it from your bundled JavaScript and gain unrestricted read/write access to your entire database.
This isn't a theoretical risk. AI code generators frequently produce this vulnerability because they optimize for functional code, not secure code. When you ask an AI to "add Supabase authentication" or "fetch user data from Supabase," it often reaches for the service-role key because that key works without RLS complexity.
The fix is architectural: service-role operations belong exclusively on the server. Client-side code uses only the anon key, which respects your RLS policies.
How AI Models Create This Vulnerability
AI models don't inherently understand the distinction between server and client execution contexts. When generating Next.js or React code that needs database access, they follow patterns from training data—and unfortunately, many tutorials and examples use the service-role key incorrectly.
The Pattern AI Follows
Here's what AI-generated code often looks like:
// lib/supabase.js - AI-generated, VULNERABLE
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY // CRITICAL BUG
export const supabase = createClient(supabaseUrl, supabaseKey)
The AI sees that NEXT_PUBLIC_ makes environment variables accessible in the browser and applies that pattern to all Supabase configuration. It doesn't recognize that the service-role key requires fundamentally different handling.
Why This Bypasses All Security
Supabase's security model relies on RLS policies that filter data based on the authenticated user. The anon key triggers these policies. The service-role key was designed for server-side admin operations—migrations, background jobs, data seeding—where you explicitly need to bypass user-level restrictions.
When the service-role key runs client-side:
- All RLS policies are ignored
- Any user can read any row in any table
- Any user can write, update, or delete any data
- Your entire database is effectively public
Detecting Service-Role Key Exposure
Code Search Patterns
Search your codebase for these signals:
# Direct service-role references
grep -r "service_role" --include="*.js" --include="*.ts" --include="*.tsx"
grep -r "serviceRoleKey" --include="*.js" --include="*.ts" --include="*.tsx"
# NEXT_PUBLIC_ with sensitive names
grep -r "NEXT_PUBLIC_.*SERVICE" --include="*.js" --include="*.ts" --include="*.env*"
grep -r "NEXT_PUBLIC_.*SECRET" --include="*.js" --include="*.ts" --include="*.env*"
grep -r "NEXT_PUBLIC_.*ROLE" --include="*.js" --include="*.ts" --include="*.env*"
Check Your Built Bundles
Even if your source looks clean, verify the production build:
# Build your project
npm run build
# Search the output
grep -r "eyJ" .next/static # JWT tokens often start with eyJ
grep -r "service_role" .next/static
If your service-role key appears anywhere in .next/static or your equivalent client bundle directory, it's exposed.
Environment Variable Audit
Review your .env files for misclassified secrets:
# .env.local - Check for this anti-pattern
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # OK - anon key is safe client-side
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ... # CRITICAL - never prefix with NEXT_PUBLIC_
The NEXT_PUBLIC_ prefix in Next.js explicitly bundles the variable into client JavaScript. This is the correct behavior for the anon key and catastrophically wrong for the service-role key.
The Correct Architecture
Separate Client and Server Supabase Clients
Create two distinct Supabase clients with clear boundaries:
// lib/supabase-client.ts - FOR CLIENT-SIDE USE ONLY
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
// This client respects RLS - safe for browsers
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// lib/supabase-admin.ts - FOR SERVER-SIDE USE ONLY
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.SUPABASE_URL! // No NEXT_PUBLIC_ prefix
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!
// This client bypasses RLS - NEVER import in client components
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey)
Environment Variable Configuration
# .env.local
# Client-safe (bundled into browser JS)
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Server-only (never reaches the browser)
SUPABASE_URL=https://yourproject.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Using the Admin Client in API Routes
// app/api/admin/users/route.ts
import { supabaseAdmin } from '@/lib/supabase-admin'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
// Verify admin authorization first
const authHeader = request.headers.get('authorization')
if (!verifyAdminToken(authHeader)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Now safe to use admin client
const { data, error } = await supabaseAdmin
.from('users')
.select('*')
return NextResponse.json({ data, error })
}
Server Components in Next.js App Router
// app/admin/dashboard/page.tsx - Server Component
import { supabaseAdmin } from '@/lib/supabase-admin'
// This runs on the server - service-role key never reaches client
export default async function AdminDashboard() {
const { data: stats } = await supabaseAdmin
.from('analytics')
.select('*')
.single()
return <DashboardView stats={stats} />
}
Related Security Patterns to Check
The service-role key issue often appears alongside other AI-generated security problems.
RLS Must Be Enabled AND Have Policies
AI sometimes enables RLS but forgets to add policies, which defaults to denying all access:
-- AI might generate this
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Without policies, this blocks all access via anon key
-- But service-role key still bypasses everything
Always verify both RLS enablement and appropriate policies exist.
Other Secrets Behind NEXT_PUBLIC_
The same pattern applies to any secret. Search for:
grep -r "NEXT_PUBLIC_.*KEY" .env*
grep -r "NEXT_PUBLIC_.*SECRET" .env*
grep -r "NEXT_PUBLIC_.*TOKEN" .env*
Stripe secret keys, API tokens, and database credentials follow the same rule: if it's sensitive, it cannot have the NEXT_PUBLIC_ prefix.
Hardcoded Keys in Source
AI models sometimes skip environment variables entirely:
// AI-generated, VULNERABLE
const supabase = createClient(
'https://xxx.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUi...'
)
This commits credentials to version control. Even if you delete the line later, the key remains in git history.
Automated Detection in Your Pipeline
Manual review catches some issues, but systematic detection requires automation. Add these checks to your CI/CD pipeline:
# .github/workflows/security.yml
- name: Check for exposed secrets
run: |
# Fail if service-role appears in client-accessible files
if grep -r "service_role\|serviceRole" --include="*.tsx" --include="*.jsx" src/components src/app; then
echo "ERROR: Service-role key reference found in client code"
exit 1
fi
# Fail if NEXT_PUBLIC_ prefixes sensitive keys
if grep -E "NEXT_PUBLIC_.*(SECRET|SERVICE|PRIVATE)" .env*; then
echo "ERROR: Sensitive key with NEXT_PUBLIC_ prefix"
exit 1
fi
For comprehensive AI code review that catches these patterns automatically, Fairy Scout provides free PR-level verification specifically designed to detect secrets exposure and other AI-generated vulnerabilities before they reach production.
What to Do If You've Already Exposed the Key
If your service-role key has been in client code:
- Rotate immediately: Go to Supabase Dashboard → Settings → API → Generate new service-role key
- Update server-side environment variables with the new key
- Redeploy all services using the key
- Audit your data for unauthorized access
- Review git history to ensure the old key isn't in previous commits (consider using git-filter-repo to remove it)
Assume compromise. The key may have been extracted by automated scanners that harvest secrets from JavaScript bundles.
Building Reliable AI-Generated Code
This vulnerability illustrates a broader pattern: AI models generate code that works but may not be secure. They don't understand execution contexts, deployment environments, or security boundaries unless explicitly constrained.
The solution isn't to avoid AI-generated code—it's to verify it systematically before production. Every PR that touches authentication, secrets, or database access needs review specifically for these patterns.
Fairy for Code provides the verification layer that catches service-role exposure, hardcoded secrets, and other critical security issues that AI assistants routinely produce. The platform applies human-verified detection patterns across your codebase, ensuring AI-generated code meets production security standards before it ships.
Frequently asked questions
What happens if my Supabase service-role key is exposed client-side?
The service-role key bypasses all Row Level Security policies, giving anyone who extracts it from your bundled JavaScript full read/write access to your entire database. This means complete data breach—all user data, all tables, no restrictions.
Why does AI put Supabase service-role keys in client code?
AI models optimize for working code, not secure code. When asked to implement Supabase features, they often reach for the service-role key because it avoids RLS complexity. The models don't distinguish between server and client execution contexts without explicit instruction.
How do I check if my service-role key is exposed?
Search your codebase for NEXT_PUBLIC_SUPABASE_SERVICE_ROLE or serviceRoleKey in any file that runs client-side. Also check your built JavaScript bundles—if the key appears there, it's exposed regardless of your source file organization.
What's the difference between Supabase anon key and service-role key?
The anon key is safe for client-side use—it respects Row Level Security policies you've defined. The service-role key bypasses all RLS and has admin-level access. Clients should only ever use the anon key.
Should I rotate my service-role key if it was exposed?
Yes, immediately. Go to your Supabase dashboard, generate a new service-role key, update your server-side environment variables, and redeploy. Assume any data accessible via that key may have been compromised.
Have AI-generated work you’d want verified? Connect with a Fairy → or run a free check with Scout.
More resources