Supabase Service Role Key Guide 2026: Secure RLS Bypass
Technology

Supabase Service Role Key Guide 2026: Secure RLS Bypass

Learn how to securely use the Supabase service role key in Next.js Edge Functions and Server Actions to bypass RLS and manage users.

2026-06-04
12 min read
Supabase Service Role Key Guide 2026: Secure RLS Bypass

TL;DR#

If you need to bypass Row Level Security (RLS) or perform administrative tasks like deleting users, you must use the service_role key. The most common failure mode is exposing this key to the browser by prefixing it with NEXT_PUBLIC_ or using it in a Client Component.

Fix this by:

  1. Storing the key in a standard environment variable (e.g., SUPABASE_SERVICE_ROLE_KEY).
  2. Initializing the Supabase client with the service role key exclusively in Server Components, API routes, or Edge Functions.
  3. Using the auth.admin namespace for user management operations.

Service Role Key vs. Anon Key: 2025/2026 Architecture#

In the current Supabase architecture, the distinction between the anon key and the service_role key is the difference between a user and a god. The anon key is designed for client-side usage. It operates within the boundaries of Row Level Security (RLS). When a user signs in, their JWT is attached to requests, and RLS policies determine what they can read or write. This is the foundation of secure, multi-tenant applications.

The service_role key, however, bypasses RLS entirely. It represents the database owner. It has full read/write access to your database and full administrative access to your Auth users. This key is intended for backend processes—migrations, cron jobs, and administrative Server Actions—where you need to manipulate data irrespective of user permissions.

A common confusion in 2025/2026 stacks is treating the service_role key as just "another API key." It is not. While the anon key is safe to expose to the public (because it is gated by RLS), the service_role key is a root credential. If you ship this key to the browser, you effectively give every visitor full administrative access to your application. They can delete all users, drop tables, and scrape your entire database.

Secure Storage: Environment Variables in Next.js and Vercel#

To use the service_role key securely, you must never hardcode it. In Next.js, this means using environment variables. However, the mechanism you use to load these variables determines their security.

In a standard Next.js application, you have two types of environment variables: those prefixed with NEXT_PUBLIC_ and those that are not.

  • NEXT_PUBLIC_ variables: These are embedded into the JavaScript bundle sent to the client. Anyone can view the source of your site and read these values.
  • Non-prefixed variables: These are only available on the server side. They are accessible in API routes, Server Components, and Edge Functions, but they are completely excluded from the client-side bundle.

For the service_role key, you must use the non-prefixed variety.

Step 1: Retrieve your keys#

Go to your Supabase project dashboard -> Settings -> API. You will see two keys: anon (public) and service_role (secret). Copy the service_role key.

Step 2: Configure Local Environment#

Create or update your .env.local file in the root of your Next.js project.

bash
# .env.local
# Supabase URL and Anon Key (used for client-side)
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

# Service Role Key (SERVER SIDE ONLY)
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here

Step 3: Configure Production (Vercel)#

If you are deploying to Vercel, you must replicate these variables in your project settings. Do not commit .env.local to git.

  1. Go to your Vercel Project -> Settings -> Environment Variables.
  2. Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY.
  3. Add SUPABASE_SERVICE_ROLE_KEY.
  4. Crucial: Ensure SUPABASE_SERVICE_ROLE_KEY is not prefixed with NEXT_PUBLIC_. Vercel treats environment variables differently based on this prefix, just like Next.js does locally.

I cover the specifics of production deployment pipelines in Deploying Next.js + Supabase to Production, but the core principle remains: isolate the secret.

Avoiding the NEXT_PUBLIC_ Leak: Server-side vs. Client-side Isolation#

The most critical error I see developers make is prefixing the service role key with NEXT_PUBLIC_ to make it "easier to access" across the app. This is a security vulnerability.

When you prefix a variable, Next.js replaces process.env.NEXT_PUBLIC_SECRET with the actual string value at build time. This string ends up in your main.js or chunks sent to the user's browser.

To verify this, you can run a production build locally:

bash
npm run build

Then, inspect the .next/server/app/page.js (or a similar chunk) or the build output in the .next/static folders. You will not find your non-prefixed variables there. If you had prefixed them, they would be visible in plain text.

The Client Component Trap#

You cannot use the service_role key inside a Client Component (a file with 'use client' at the top). Client Components run in the browser. If you try to access process.env.SUPABASE_SERVICE_ROLE_KEY inside a Client Component, it will return undefined.

This is a feature, not a bug. It forces you to move your administrative logic to the server.

If you have a button in the frontend that needs to trigger an admin action (e.g., "Ban User"), do not call the Supabase client directly in the component. Instead, create a Server Action or an API route.

Implementing the Supabase Admin Client in Edge Functions#

When working with Supabase in a server context, you should use the createClient function from @supabase/supabase-js. The difference lies in which keys you pass to it.

For standard user operations, you use the anon key. For admin operations, you use the service_role key.

Here is how to instantiate the Admin client in a utility file. I recommend creating a dedicated file for this to avoid accidentally importing the wrong client.

ts
// lib/supabaseAdmin.ts
import { createClient } from '@supabase/supabase-js'

// These variables are only available on the server
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!

if (!supabaseUrl || !supabaseServiceRoleKey) {
  throw new Error('Missing environment variables for Supabase Admin Client')
}

export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, {
  auth: {
    autoRefreshToken: false,
    persistSession: false
  }
})

Notice the auth configuration. Since this is a backend service, we don't need to handle session persistence or auto-refreshing tokens. We are acting as the system.

Using the Admin Client in an Edge Function#

Next.js Edge Functions are a great place to run administrative logic because they are lightweight and close to your user. You can use the Admin Client here direct.

ts
// app/api/admin/delete-user/route.ts
import { supabaseAdmin } from '@/lib/supabaseAdmin'
import { NextResponse } from 'next/server'

export const runtime = 'edge'

export async function POST(request: Request) {
  try {
    const { userId } = await request.json()

    if (!userId) {
      return NextResponse.json({ error: 'Missing userId' }, { status: 400 })
    }

    // Using the auth.admin namespace to delete a user
    const { data, error } = await supabaseAdmin.auth.admin.deleteUser(
      userId
    )

    if (error) {
      return NextResponse.json({ error: error.message }, { status: 400 })
    }

    return NextResponse.json({ success: true, userId })
  } catch (error) {
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
  }
}

This API route can now be called from your frontend. Even though the request originates from the browser, the logic executes on the server (or edge), where the SUPABASE_SERVICE_ROLE_KEY is accessible.

For more advanced patterns involving Edge Functions, check out Mastering Supabase Edge Functions with Next.js.

Bypassing Row Level Security (RLS) for Administrative Tasks#

The primary use case for the service_role key is interacting with the database while bypassing RLS. Let's look at a concrete example.

Imagine you have a profiles table with RLS enabled.

sql
-- Policy: Users can only update their own profile
create policy "Users can update own profile"
on profiles
for update
using (auth.uid() = id);

If a user tries to update another user's profile using the standard client, the database rejects it.

However, as an administrator, you might need to update a user's status (e.g., banning them) regardless of who owns the row. The service_role key ignores this policy.

Example: Bulk Update via Server Action#

Here is a Next.js Server Action that updates all users matching a criteria. This would be impossible with the anon key without disabling RLS entirely.

ts
// app/actions/admin-actions.ts
'use server'

import { supabaseAdmin } from '@/lib/supabaseAdmin'
import { revalidatePath } from 'next/cache'

export async function banUser(userId: string) {
  // This update bypasses the "Users can update own profile" policy
  const { data, error } = await supabaseAdmin
    .from('profiles')
    .update({ is_banned: true, updated_at: new Date().toISOString() })
    .eq('id', userId)
    .select()

  if (error) {
    console.error('Failed to ban user:', error)
    return { success: false, error: error.message }
  }

  // Revalidate the cache to show the updated UI
  revalidatePath('/admin/users')

  return { success: true, data }
}

When you call supabaseAdmin, Supabase identifies the request as coming from a service role. It effectively sets role to postgres (or a service role), causing RLS policies to be skipped for the duration of that connection.

Practical Use Cases: User Management and Bulk Updates#

Beyond simple RLS bypass, the service_role key unlocks the auth.admin API. This is distinct from the standard auth API.

1. Deleting a User and Their Data#

When a user deletes their account, you usually want to delete their auth record and their profile data. The client-side SDK can delete the auth session, but it cannot delete the user from the auth.users table (a protected system table) due to security restrictions. Only the service_role key can do this.

ts
import { supabaseAdmin } from '@/lib/supabaseAdmin'

export async function deleteUserCompletely(userId: string) {
  // 1. Delete the user from Auth (requires service_role)
  const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(
    userId
  )

  if (authError) {
    return { success: false, error: authError.message }
  }

  // 2. Delete their data from public tables (RLS is bypassed)
  const { error: dbError } = await supabaseAdmin
    .from('profiles')
    .delete()
    .eq('id', userId)

  if (dbError) {
    return { success: false, error: dbError.message }
  }

  return { success: true }
}

2. Impersonating a User (Debugging)#

Sometimes you need to see exactly what a user sees to debug a problem. You can use the service_role key to generate a user's access token on the fly.

ts
const { data, error } = await supabaseAdmin.auth.admin.getUserById(userId)

if (data?.user) {
  // You now have the full user object and can inspect their metadata
  console.log('User metadata:', data.user.user_metadata)
}

This is incredibly powerful for debugging issues like Supabase Auth Session Not Persisting After Refresh, as it allows you to verify the state of the user in the auth system directly.

Local Development vs. Production Environment Configuration#

The behavior of environment variables differs between local development and production, which often leads to "it works on my machine" bugs.

Local Development#

When running npm run dev, Next.js loads variables from .env.local. You can access process.env.SUPABASE_SERVICE_ROLE_KEY in Server Components immediately.

However, if you are using a library that runs in the browser (like a component library that internally uses Supabase), you must ensure that library is configured with the anon key, not the service role key. A common pattern is to have two client files:

  1. lib/supabaseClient.ts (uses anon key, for Client Components).
  2. lib/supabaseAdmin.ts (uses service_role key, for Server Components).

Production#

In production (Vercel, Netlify, Docker), the .env file is not read. You must set these variables in the hosting platform's dashboard.

A frequent failure mode is forgetting to add the SUPABASE_SERVICE_ROLE_KEY to the production environment variables. The build might succeed, but at runtime, your API routes will crash with a generic 500 error because process.env.SUPABASE_SERVICE_ROLE_KEY is undefined.

Always verify your deployment environment variables match your local .env.local file (minus the NEXT_PUBLIC_ secrets).

Troubleshooting: Why is my service_role_key not bypassing RLS?#

If you have implemented the Admin client but RLS is still blocking your requests, check these three scenarios.

Scenario 1: You are using the wrong client#

It is easy to accidentally import the browser client instead of the admin client.

ts
// WRONG
import { supabase } from '@/lib/supabaseClient' // Uses anon key

// RIGHT
import { supabaseAdmin } from '@/lib/supabaseAdmin' // Uses service_role key

If you use the client initialized with the anon key, RLS will apply based on the user context (which might be null if called from a server context without a session).

Scenario 2: The key is actually the Anon key#

Copy-paste errors happen. Double-check that the value in SUPABASE_SERVICE_ROLE_KEY actually starts with eyJ... (it's a JWT) and corresponds to the "service_role" secret in your dashboard, not the "anon" public key.

Scenario 3: Database Triggers or Constraints#

Sometimes it's not RLS blocking you. It might be a database constraint (e.g., a NOT NULL violation) or a trigger function that is failing. The error message returned by Supabase usually distinguishes between RLS errors ("permission denied") and constraint errors ("null value in column...").

If you see "permission denied for schema public", you are definitely hitting RLS or a schema permission issue. If you see "new row violates row-level security policy", it is specifically an RLS policy issue.

Risk Mitigation: Handling and Rotating Compromised Keys#

If you suspect your service_role key has been leaked (e.g., found in a GitHub repo or browser bundle), you must rotate it immediately.

  1. Go to the Supabase Dashboard -> Settings -> API.
  2. Click "Regenerate" next to the service_role key.
  3. Important: This will invalidate the old key instantly.
  4. Update your .env.local file.
  5. Update your production environment variables (Vercel/Netlify).
  6. Redeploy your application.

There is no "grace period" for rotation. Once you regenerate, the old key is dead. Ensure you have the new value ready to paste into your production environment before you hit save, or your app will go down until you update the env vars.

To prevent leaks, configure a git pre-commit hook that checks for SUPABASE_SERVICE_ROLE_KEY in staged files. Tools like git-secrets or simple grep scripts in your package.json can save you from accidental commits.

Summary Checklist for Secure Implementation#

Before you ship that admin feature, run through this checklist:

  • [ ] Storage: Is the key in a variable named SUPABASE_SERVICE_ROLE_KEY (no NEXT_PUBLIC_ prefix)?
  • [ ] Scope: Is the key only imported and used in Server Components, API Routes, or Edge Functions?
  • [ ] Client Separation: Do I have two distinct client files (supabaseClient.ts vs supabaseAdmin.ts)?
  • [ ] Production: Have I added the variable to my Vercel/Netlify/Docker environment?
  • [ ] Verification: Did I test the endpoint and confirm it bypasses RLS?
  • [ ] Rotation: Do I know how to regenerate the key if it leaks?

Using the service_role key is the standard way to handle administrative logic in the Supabase + Next.js stack. It allows you to build powerful internal tools and background jobs while maintaining strict security for your end users. If you run into issues with email delivery during user management, I've documented common fixes in Supabase Email Confirmation Not Sending Troubleshooting. For those looking to expand their stack further, AI Integration for Next.js + Supabase Applications often relies on these secure admin patterns to handle vector embeddings and data processing.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.