Fix Supabase "Forbidden use of secret API key in browser"
Forbidden use of secret API key in browser means Supabase received a secret key (sb_secret_…) on a request with a browser-style User-Agent, and refused it. If a browser really sent it, the key is public, so rotate it and move the call to the server; if a script, CI job or Edge Function sent it, give that client an honest, non-browser User-Agent.
The full response, as pasted from a failing sync job in taxdeed-scraper#8:
{
"message": "Forbidden use of secret API key in browser",
"hint": "Secret API keys can only be used in a protected environment and should never be used in a browser. Delete this secret API key immediately!"
}
Logs pair it with the code UNAUTHORIZED_INVALID_API_KEY_TYPE (Jo-Voice-app#9, ToursRed#265). Supabase's API returns it, not your client library: PowerShell scripts and Python requests sessions get the same response with no supabase-js involved. As of 24 October 2026, GitHub search finds 27 issues and pull requests quoting the full message, the newest from 20 October.
What does "Forbidden use of secret API key in browser" mean?
It means the request carried a secret key and a browser-looking User-Agent, so Supabase refused it. The API keys docs describe the whole check in one line: "A secret key doesn't work in a browser. Supabase matches on the User-Agent header and returns HTTP 401 Unauthorized."
Two things follow. The check can't tell a browser from a server that sends Mozilla/5.0, so one error covers two situations with different fixes. And it isn't a lock: the same page warns that "an attacker can still use the key from other tools." The publishable vs secret keys guide calls it a seatbelt for that reason.
Match on the message, not the status: the docs say 401, but kouden#216 got a 403 calling the Admin API from PowerShell 5.1. It's also a different error from Invalid API key, which means the key doesn't match the project.
So start with one question: where did the failing request come from? If it shows up in your browser's Network tab with an apikey header starting sb_secret_, you're in case 1. If a script, cron job, CI runner or Edge Function sent it, you're in case 2.
Case 1: the secret key really is in the browser
Here the error is doing its job, and the key is already public. In the reports, the cause is usually a value rather than code: the browser's key variable holds the secret key. In nexo-social#6, NEXT_PUBLIC_SUPABASE_ANON_KEY on Vercel held an sb_secret_ key. arbdesk4#7 put the secret key in the same variable, and Vite apps do it with VITE_SUPABASE_ANON_KEY (gardenfinance#5). The name says anon, the value says secret, and the build inlines the value into every visitor's bundle.
The committed version of that mistake:
# .env.production
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
# Pasted from Settings > API Keys. Sign-in now fails with
# "Forbidden use of secret API key in browser".
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_secret_N4vT8qLm2RzX7cW1pKd9Yh3B
- Critical.env.production:4
Hardcoded secret in source
Move the secret to an environment variable, purge it from git history (e.g. git filter-repo / BFG), and rotate it at the provider.
The variable name passes name-based checks, because an ANON key is meant to be public. The finding comes from the value: general/hardcoded-secret recognizes the sb_secret_ format in any scanned file. GuardLayer also flags Next.js client components that read a secret-key variable (supabase/service-role-key-in-client) and NEXT_PUBLIC_ variables named like a secret (supabase/service-role-in-public-env, nextjs/public-env-secret).
What it does not catch:
- A value set only in Vercel's or Netlify's environment settings. A repo scan can't see it; check the built bundle (self-check below).
- Vite code reading
import.meta.env.VITE_SUPABASE_SECRET_KEY. No rule fires on that shape today, though a committedsb_secret_literal in a Vite file is still caught. - Plain
.htmlfiles, which aren't scanned. Single-page HTML tools turn up in these threads too (HotPickSports#243).
How do I fix it when the key is in the browser?
Put the publishable key where the secret one was, then rotate. Supabase's guide says to fix the root cause of a leak before you rotate anything, so the new key doesn't ship in the same bundle. In order:
- Fix the browser variable. Set it to the publishable key (
sb_publishable_…) and redeploy:NEXT_PUBLIC_andVITE_values are baked in at build time. - Move the privileged call to the server: a route handler or server action, with the secret-key client in a module that starts with
import "server-only". A Vite SPA has no server of its own, so use an Edge Function. - Rotate. Supabase's steps: create a new secret key in Settings > API Keys, replace the old one everywhere your server code uses it, confirm every component uses the new one, then delete the leaked key. Deletion can't be undone, so don't skip the confirmation.
Swapping supabase-js for a raw fetch() isn't a fix. Whatever the response, the key is still in the bundle.
Case 2: server code that looks like a browser
Because the check matches on the User-Agent, a server process that sends a browser-style one is refused too. Each pattern below comes from a real report:
- PowerShell. Microsoft's docs give the default
User-AgentofInvoke-RestMethodas starting withMozilla/5.0. taxdeed-scraper#8 captured PowerShell 7.4.6 sendingMozilla/5.0 (Linux; Ubuntu 24.04.4 LTS; ) PowerShell/7.4.6, and one CI run discarded 741 harvested properties when its sync step failed. APP-RONCHIVERDI#44 hit it from anInvoke-WebRequestloop after setting a custom agent only onInvoke-RestMethod. - Google Apps Script. Google documents
UrlFetchApp's default asMozilla/5.0 (compatible; Google-Apps-Script; …). The author of sss-library#5 reports that Apps Script strips any customUser-Agent, so move the secret-key call out of Apps Script: into an Edge Function that holds the key and checks a secret of its own, or into a scheduled GitHub Actions job. - A scraper's session reused for Supabase. In james-river-gooners#219, a
requestssession with a ChromeUser-Agentwas reused for the Supabase calls: 401 with the browser UA, 200 with an override. SmartMealPlanner#19 reused its store fetchers' SafariUser-Agentthe same way. - Edge Functions forwarding the caller's headers. ToursRed#265 passed the browser's
user-agentinto its secret-key clients for audit logging. The fix touched 50 of them across 49 functions, and the failures were mostly silent because those functions didn't checkerror. Every request that got through carriedDeno/…orStripe/1.0. In ki67#2, a shared secret-key client forwarding the browser'sUser-Agentmeant 2FA codes were never generated.
The fix is one header on the client that holds the secret key, or not forwarding one:
Invoke-RestMethod -Uri "$env:SUPABASE_URL/rest/v1/orders" -Method Post `
-Headers @{ apikey = $env:SUPABASE_SECRET_KEY } `
-UserAgent "orders-sync/1.0" -ContentType "application/json" -Body $json
// supabase/functions/_shared/admin.ts
import { createClient } from "npm:@supabase/supabase-js@2";
const secretKeys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS")!);
// No forwarded request headers. Store the caller's user-agent as a column value.
export const admin = createClient(Deno.env.get("SUPABASE_URL")!, secretKeys["default"], {
auth: { persistSession: false },
});
Overriding the User-Agent isn't cheating when the code runs on a machine you control: a CI job that says orders-sync/1.0 is telling the truth. It never makes a key in client code acceptable.
The scanner mostly can't see this case. No rule flags forwarding user-agent into a secret-key client: an Edge Function that verifies its caller and then forwards the header gets no findings. One with no caller check gets supabase/edge-function-no-auth, which is about the missing check, not the header. Fix that anyway: an Edge Function is public by default, and one holding a secret key is an open door to your data.
Should I switch back to the legacy service_role key?
No. Some threads reach for it (sss-library#5's setup notes now say to use the legacy service_role JWT from Apps Script), but it's a dead end: Supabase's docs say it is "deprecating the anon and service_role keys by the end of 2026." And once someone turns the legacy pair off, the fallback breaks with Legacy API keys are disabled.
A 60-second self-check
# 1. Did a secret key reach a client bundle? (Next.js, then Vite)
grep -rl "sb_secret_" .next/static dist 2>/dev/null
# 2. Any browser-exposed variable named like a secret?
grep -rnE "(NEXT_PUBLIC|VITE)_[A-Z_]*(SECRET|SERVICE_ROLE)" . --exclude-dir=node_modules --exclude-dir=.next
# 3. Edge Functions passing the caller's user-agent along?
grep -rn -i "user-agent" supabase/functions
A hit on 1 means the key is public. Find the cause before you rotate: open your host's environment settings and read the value of the browser key variable, not just its name.
FAQ
Is "Forbidden use of secret API key in browser" a Supabase bug?
No. It's a deliberate block on secret keys sent with a browser User-Agent: either the key is in client code, or your server code looks like a browser.
Why do I get it from GitHub Actions or a cron job?
Your HTTP client sends a browser-style User-Agent; PowerShell's and Apps Script's defaults both start with Mozilla/5.0. Set an honest one, such as nightly-sync/1.0, on the client holding the secret key.
Supabase blocked the request. Do I still need to rotate the key?
Yes, if the key reached a browser. Anyone who copied it from your bundle can send it with any User-Agent they like.
Why is it a 401 for some people and a 403 for others? The docs say 401; one report calling the Admin API from PowerShell 5.1 got 403. Match on the message.
Can I use the publishable key in the browser? Yes, that's what it's for. What it can reach is limited by your grants and Row Level Security policies, and Supabase documents the browser block for secret keys only.
Does GuardLayer catch this?
It catches the code-side causes: a secret key read in a Next.js client component, a NEXT_PUBLIC_ variable named like a secret, and committed sb_secret_ literals. It can't see values set only in your host's dashboard, Vite import.meta.env secret reads, .html files, or User-Agent forwarding.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.