Fix: Invalid API key error in Supabase
Supabase's Invalid API key error — hint: "Double check your Supabase anon or service_role API key" — means the key your client sent isn't a valid, active key for the project at the URL it called. It's almost always a key from a different project or environment, a key that's been rotated or disabled, or a key sent the wrong way. Fix the pairing between URL and key. Don't follow the hint's implication and swap in the service_role key — that's how the most severe Supabase vulnerability gets shipped.
Here's the full response, as posted verbatim in supabase/supabase#38611:
{"message":"Invalid API key","hint":"Double check your Supabase `anon` or `service_role` API key."}
It's pasted so often that it's the entire title of realtime-js#271, closed without a resolution.
Why does the error tell me to check my service_role key?
Because the hint names the only two key types it was written for, and it says the same thing whatever went wrong. It isn't telling you that service_role is the answer.
In 2026 that wording actively misleads. Plenty of projects run on the newer sb_publishable_… and sb_secret_… keys, or on their own JWT signing keys, and get told to check a key they don't use. The hint is generic; don't read it as a diagnosis.
What actually causes it
Work through these in order. The first one covers most reports.
1. The URL and the key belong to different projects. Two projects, two sets of keys, and one environment variable file that mixes them. The common versions:
- Keys from local
supabase startwith the hosted project URL, or the reverse. - Staging keys in production. Vercel scopes environment variables per environment, so Preview can hold different values from Production — see what happens when those variables don't load at all.
- A second project created for testing whose keys ended up in
.env.local.
A legacy anon key is a JWT, so you can check which project it belongs to without the dashboard:
echo "$NEXT_PUBLIC_SUPABASE_ANON_KEY" | cut -d. -f2 | base64 -d 2>/dev/null; echo
The payload includes a ref claim. It must match the subdomain of your SUPABASE_URL. (If base64 complains about padding, append one or two = to the middle segment.) While you're looking, confirm role says anon — more on that below.
2. The key was rotated or disabled. Supabase's docs are explicit that creating new publishable and secret keys doesn't disable the legacy pair: legacy keys "stay valid until you disable them" in Settings → API Keys, as a separate step. If someone on your team did that step and your deployed app still ships the old anon JWT, it will be refused. Same if the legacy JWT secret was regenerated. Supabase has also said it is deprecating the anon and service_role keys by the end of 2026 — plan the migration rather than waiting for this error to announce it.
3. A new-format key sent as a JWT. Publishable and secret keys aren't JWTs. The migration guide warns that if you also send the key in Authorization: Bearer, the platform "tries to parse it as a JWT and rejects the request with Invalid JWT." Different message, same root cause — a mismatch between key type and how it's sent. Publishable vs secret keys covers which key goes where.
4. A secret key used from a browser. Per the API keys docs: "A secret key doesn't work in a browser. Supabase matches on the User-Agent header and returns HTTP 401 Unauthorized." That rejection is Supabase deliberately stopping the mistake in the next section. Treat it as a guardrail working, not as a bug to route around.
5. Custom JWT signing keys. #38611 reports custom JWTs signed with an ES256 key uploaded under JWT Signing Keys returning this exact error, even though the logs show the token parsing correctly — while the legacy HS256 setup works. It's open. If that's your setup, it's likely you and not your key; the background is in the JWT signing keys migration.
The fix that makes it worse
Somewhere in the debugging, someone tries the other key. It works. Here's what that looks like in a Next.js app:
- Criticallib/supabase/client.ts:8
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. - Criticallib/supabase/client.ts:8
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. - Criticallib/supabase/client.ts:8
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.
Three critical findings, one mistake. Everything prefixed with NEXT_PUBLIC_ is inlined into the JavaScript bundle at build time, and the file is a client component. The service role key is now in every visitor's browser, readable from DevTools in about ten seconds.
The service role bypasses Row Level Security entirely. It doesn't matter how carefully your policies are written: whoever holds this key can read, modify and delete every row in every table. An exposed service role key isn't a partial compromise — it's the database.
It "fixed" the error for a revealing reason: the service_role key was valid and the anon key wasn't. The actual bug — a wrong or stale anon key — is still there. You've just swapped it for a key that must never leave the server.
If you've already shipped this, removing the line isn't enough. Rotate the key; assume it's been copied.
The correct setup
// lib/supabase/client.ts — browser. Publishable (or legacy anon) key only.
"use client";
import { createBrowserClient } from "@supabase/ssr";
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
// lib/supabase/admin.ts — server only. Never imported by a client component.
import "server-only";
import { createClient } from "@supabase/supabase-js";
export const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
);
The server-only import turns an accidental client-side import into a build error, which is exactly when you want to find out.
Quick self-check
# Any privileged key exposed to the bundle, or used near "use client"?
grep -rnE "NEXT_PUBLIC_[A-Z_]*(SERVICE_ROLE|SECRET)" . --include="*.ts" --include="*.tsx" --include=".env*"
grep -rln "use client" app components lib | xargs grep -lnE "SERVICE_ROLE|sb_secret_"
Both should return nothing. Then decode the key you ship to the browser, as above, and confirm the payload says "role":"anon" and the ref matches your URL. If it says service_role, stop and rotate.
FAQ
My URL and key are definitely right. What else?
Check whether legacy keys were disabled in Settings → API Keys, and whether the deployed environment has the same values as your local .env. Redeploy after changing env vars — NEXT_PUBLIC_ values are baked in at build time.
It works for Auth but fails for Realtime or Storage. Why? Each service validates the key it receives. If one client instance was created with a different key, or a service is reached through a different client, the key can differ per request. Log the key prefix each client uses.
Is it safe to use the service_role key in a Server Component?
On the server, yes — as long as the module is never imported into client code and the variable isn't NEXT_PUBLIC_. Guard it with import "server-only".
What's the difference between "Invalid API key" and "Invalid JWT"?
Invalid API key rejects the apikey itself. Invalid JWT means something tried to parse a token as a JWT — often a new-format key sent in the Authorization: Bearer header.
Do I need to migrate off anon and service_role keys? Supabase says it's deprecating them by the end of 2026. Migrating to publishable and secret keys also gets you the browser guard on secret keys.
Catch this before it ships — free
GuardLayer scans every push for this and 33 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.