Fix Supabase 'AuthApiError: User not allowed'
AuthApiError: User not allowed means you called a supabase.auth.admin.* method with a key that has no admin privileges — an anon/publishable key, or an SSR client that overwrote the service-role token with the user's session. Fix it by building a separate service_role client in server-only code. Never move that key into the browser to make the error go away.
The admin API is the small set of methods that act on other users: listUsers, createUser, deleteUser, updateUserById, inviteUserByEmail, generateLink. They're privileged by definition, so Supabase's Auth server checks the role in your key before it does anything, and returns a flat refusal if it isn't service_role. From discussion #5434, the diagnosis in one line: you don't have a key with permission to access that function.
There are two ways to get there, and only one of them is obvious.
Cause 1: you're using the anon key
The common case. Your app has one Supabase client, it's built with NEXT_PUBLIC_SUPABASE_ANON_KEY, and you called an admin method on it:
// This client is anon-scoped. auth.admin.* will always refuse.
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
await supabase.auth.admin.listUsers(); // AuthApiError: User not allowed
The anon key is designed to be public and gated by RLS — that's exactly why it's safe in your bundle, and exactly why it can't administer users. Same story for the newer sb_publishable_ key.
Cause 2: an SSR client silently downgraded your service-role key
This one is genuinely non-obvious, and it's the reason people swear the key is right and the error persists.
createServerClient from @supabase/ssr (and the old @supabase/auth-helpers) exists to attach the signed-in user's session to every request. It reads the auth cookie and sets that JWT as the Authorization header. So if you pass the service-role key into an SSR client, the user's access token overwrites it on the wire, and Auth sees an ordinary logged-in user:
// Looks right. Isn't. The cookie session wins over the service_role key.
const supabase = createServerClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} },
});
await supabase.auth.admin.deleteUser(id); // AuthApiError: User not allowed
Admin operations need a plain createClient with no cookie plumbing at all.
How do I call auth.admin from Next.js without this error?
Create a second, dedicated Supabase client with the service_role (or sb_secret_) key in a server-only module, and call it from a Route Handler or Server Action — never from a component that ships to the browser.
In practice that's one small module you never import from client code:
// lib/supabaseAdmin.ts
import "server-only"; // build-time error if a client file imports this
import { createClient } from "@supabase/supabase-js";
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!, // note: no NEXT_PUBLIC_ prefix
process.env.SUPABASE_SERVICE_ROLE_KEY!, // or sb_secret_...
{
auth: {
autoRefreshToken: false, // there is no user session to refresh
persistSession: false, // never write this token to storage
},
}
);
Then call it from a Route Handler or Server Action — and do your own authorization check first, because the service role bypasses everything:
// app/api/admin/users/route.ts
import { createClient } from "@/lib/supabase/server";
import { supabaseAdmin } from "@/lib/supabaseAdmin";
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser(); // verifies the JWT
// The admin key has no concept of "who is asking". You must decide.
if (user?.app_metadata?.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
const { data, error } = await supabaseAdmin.auth.admin.listUsers();
if (error) return new Response(error.message, { status: 500 });
return Response.json(data);
}
Two details that matter more than they look:
- Authorize with
getUser(), notgetSession(). On the server,getSession()reads the cookie without verifying the signature, so a forged cookie walks straight past your admin gate — the full comparison is here. - Read the role from
app_metadata, notuser_metadata.user_metadatais writable by the user viaupdateUser(), sorole: "admin"stored there is self-service promotion. The distinction is the whole ballgame.
The fix that turns a 403 into a full database breach
Here's how this error actually causes incidents. The admin call is in a client component. The dev learns they need the service-role key. They put it in the component. It's undefined in the browser, so they add the NEXT_PUBLIC_ prefix to make it available — and it works:
"use client";
import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
);
- Criticalapp/admin/UserTable.tsx:7
Service role key exposed to the client
Never prefix the service role key with NEXT_PUBLIC_. Read it only in server code via process.env.SUPABASE_SERVICE_ROLE_KEY, and rotate the key immediately since it has been exposed. - Criticalapp/admin/UserTable.tsx:7
Service role key used in client-side code
Move all service-role usage into a server context (Route Handler, Server Action, or server-only module). On the client use only the anon key, protected by RLS. - Criticalapp/admin/UserTable.tsx:7
Secret exposed through NEXT_PUBLIC_
Drop the NEXT_PUBLIC_ prefix and read the value only on the server. Publishable/anon keys are fine to expose; secret keys, tokens, and passwords are not — rotate any that have shipped.
NEXT_PUBLIC_ values are inlined into the JavaScript bundle at build time and shipped to every visitor. The service_role key bypasses every RLS policy you've ever written, so anyone who opens DevTools can now read and write every row in every table — and delete users, since that's what you were trying to do in the first place. This is the single most expensive mistake available on Supabase, and bots scrape public bundles and repos for this exact token continuously.
If you've already shipped it: rotate the key in the dashboard immediately, redeploy, then purge it from git history. The old key must be treated as burned.
Quick self-check
# 1. Admin API called anywhere that also says "use client"?
grep -rln '"use client"' --include=*.tsx --include=*.ts . | xargs grep -ln "auth.admin"
# 2. Service role key behind a public prefix?
grep -rn "NEXT_PUBLIC_.*SERVICE_ROLE\|NEXT_PUBLIC_.*SECRET" .
# 3. Service role key passed to an SSR client (it will be overridden)?
grep -rn "createServerClient" -A 2 --include=*.ts . | grep -i "SERVICE_ROLE"
Hit on #1 or #2 means rotate the key today. Hit on #3 is your User not allowed — swap that call to a plain createClient. GuardLayer flags all three on every push, along with the rest of the Next.js + Supabase rule set, so a fix made under pressure doesn't quietly ship the key.
FAQ
What causes "AuthApiError: User not allowed" in Supabase?
Calling a supabase.auth.admin.* method with a client that isn't authenticated as service_role — usually the anon/publishable key, or an SSR client whose cookie session replaced the service-role token.
Can I use the service role key in a Server Component?
Prefer a Route Handler or Server Action. Server Components are server-side, but the module boundary is easy to blur — one import from a "use client" file and the key is bundled. Guard the module with import "server-only" either way.
Why doesn't my service role key work with createServerClient?
Because @supabase/ssr attaches the user's session JWT to every request, overriding the key you passed. Use createClient from @supabase/supabase-js with no cookie handling for admin work.
Do I still need to check permissions if I'm using the service role key? Yes — more than ever. The service role bypasses RLS entirely, so the only thing standing between a caller and every user record is the authorization check you write in your route handler.
Is sb_secret_ the same as service_role?
It's the replacement. sb_secret_ keys carry the same RLS-bypassing power as service_role and are equally server-only, with the advantage that they can be rotated independently — the key model is changing, and it's worth migrating.
Catch this before it ships — free
GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.