Supabase responds with "not_admin: User is not an admin" when you attempt to call an admin-only operation using a regular user JWT instead of a service_role key. Admin operations like createUser, updateUserById, and deleteUser require elevated privileges that bypass Row Level Security and can only be executed on trusted servers with the service_role key.
The Supabase Auth Admin API provides privileged operations that allow creating, updating, and deleting users without normal authentication constraints. These operations live under the `supabase.auth.admin` namespace and require a JWT with the `service_role` or `supabase_admin` role claim. When you call these methods with a regular user token (authenticated via the anon key), Supabase checks the JWT's role claim and immediately rejects the request with "not_admin: User is not an admin" because: - Regular user JWTs have the `authenticated` role, not `service_role` or `supabase_admin` - Admin operations bypass Row Level Security (RLS), which would be a security risk if available to regular users - The service_role key creates a client with superuser privileges meant only for trusted server environments The fix always involves moving admin operations to a secure server-side context and using the service_role key instead of the public anon key.
Admin operations must run on a trusted server (Next.js API routes, Edge Functions, backend services) using the service_role key:
// lib/supabase-admin.ts (server-only file)
import { createClient } from '@supabase/supabase-js'
// Never expose this in client code
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // NOT the anon key
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
)Ensure your environment variables are set correctly:
# .env.local (server-side only)
SUPABASE_URL=https://yourproject.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc... # From Project Settings → API → service_roleThe service_role key bypasses RLS and grants full database access, so keep it secret and server-side only.
Admin methods cannot run in client-side code. Move them to Next.js API routes, server actions, or Edge Functions:
// app/api/admin/create-user/route.ts
import { supabaseAdmin } from '@/lib/supabase-admin'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
// Verify the request is authorized (implement your own auth check)
const session = await getServerSession()
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const { email, password } = await request.json()
const { data, error } = await supabaseAdmin.auth.admin.createUser({
email,
password,
email_confirm: true
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
return NextResponse.json({ user: data.user })
}Client-side code then calls your API endpoint instead of using admin methods directly:
// Client-side component
const response = await fetch('/api/admin/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})Environment variables prefixed with NEXT_PUBLIC_, VITE_, or REACT_APP_ are bundled into client code and publicly accessible. Never use these prefixes for the service_role key:
❌ Wrong (exposes to client):
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...✅ Correct (server-only):
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...In your code, verify the client is initialized correctly:
// For regular users (client-side)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// For admin operations (server-side ONLY)
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // No NEXT_PUBLIC prefix
)Check your browser's Network tab or inspect bundled JavaScript to ensure the service_role key never appears.
Even when using the service_role key on the server, add your own authorization logic to prevent unauthorized users from triggering admin operations:
import { supabaseAdmin } from '@/lib/supabase-admin'
import { createClient } from '@supabase/supabase-js'
export async function POST(request: NextRequest) {
// Create a regular client to verify the user's session
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check custom claims or a roles table
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (profile?.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Now safe to use admin operations
const { data, error } = await supabaseAdmin.auth.admin.updateUserById(
targetUserId,
{ email: newEmail }
)
return NextResponse.json({ data, error })
}This prevents regular users from calling your admin endpoints even if they discover the API route.
### JWT role claims and the service_role key
When you initialize a Supabase client with the service_role key, the JWT issued by that client includes a role claim set to service_role. This role bypasses all Row Level Security policies and grants unrestricted access to your database. Regular user JWTs have the authenticated role, which respects RLS policies. The not_admin error specifically checks for the presence of service_role or supabase_admin in the JWT's role claim before allowing admin operations.
### Difference between anon key and service_role key
- Anon key: Safe to expose in client code, respects RLS, only allows operations permitted by your security policies
- Service_role key: Must remain secret, bypasses RLS, grants full database access including auth.users modifications
You can find both keys in your Supabase dashboard under Project Settings → API. Never commit the service_role key to version control or expose it in client bundles.
### Edge Functions and service_role access
Supabase Edge Functions can access the service_role key via environment variables without exposing it to clients:
// supabase/functions/admin-operation/index.ts
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => {
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { data, error } = await supabaseAdmin.auth.admin.listUsers()
return new Response(JSON.stringify({ data, error }), {
headers: { 'Content-Type': 'application/json' }
})
})Deploy the function with supabase functions deploy and call it from client code using the anon key.
### Custom roles and RBAC
For more granular control, implement custom roles using Postgres RLS policies and custom JWT claims via Auth Hooks. This lets you grant specific admin privileges to certain users without exposing the full service_role key. See the Supabase documentation on Custom Claims & Role-based Access Control for implementation details.
email_address_not_authorized: Email sending to this address is not authorized
Email address not authorized for sending in Supabase Auth
reauthentication_needed: Reauthentication required for security-sensitive actions
Reauthentication required for security-sensitive actions
no_authorization: No authorization header was provided
How to fix "no authorization header was provided" in Supabase
otp_expired: OTP has expired
How to fix 'otp_expired: OTP has expired' in Supabase
bad_oauth_state: OAuth state parameter is missing or invalid
How to fix 'bad_oauth_state: OAuth state parameter missing' in Supabase