Article · Apr 23, 2026
Migrating to Supabase publishable keys broke my Chrome extension. Here is the fix.
Supabase publishable keys return 401 from hand-rolled fetch. Migration to @supabase/supabase-js with a chrome.storage.local session adapter.
If you migrated from Supabase’s legacy JWT keys (eyJhbGc…) to the new publishable-key format (sb_publishable_…) and your Chrome extension started returning 401 on every authenticated request, the cause is two unrelated changes colliding. PostgREST behaves differently with the new key format, and the Supabase JS SDK’s default session storage doesn’t work in a Manifest V3 service worker. Here is what’s happening, the migration that resolves it, and the chrome.storage.local adapter that keeps sessions alive between popup opens.
Why does the publishable key return 401?
Publishable key: Supabase’s new API key format (prefixed
sb_publishable_). Unlike the legacyanonJWT key, it is not a JSON Web Token. PostgREST cannot accept it as a standalone credential; it only validates when the official@supabase/supabase-jsclient sends it alongside a properly-formed user JWT.
The legacy Supabase API keys (anon and service_role) were JWTs. You could send them as a raw apikey header to PostgREST and authentication worked. The new publishable keys are not JWTs. PostgREST accepts them as an apikey header only when the request also presents a properly-formed user JWT in the Authorization header, in the exact shape the official @supabase/supabase-js client produces.
Send sb_publishable_… from a hand-rolled fetch() wrapper and one of two things happens:
- If you omit the user’s JWT: 401 Unauthorized.
- If you put the publishable key into
Authorization: Bearer …: 401 Unauthorized, because it is no longer valid to place a publishable or secret key inside the Authorization header. The Supabase team called this out in the API key migration announcement:Authorizationis for the user’s JWT only.
The fix is to stop hand-rolling Supabase HTTP calls. The official client knows how to combine the publishable key, the user’s session token, and the Authorization header automatically. If your code was already on the SDK, the publishable-key migration is a single env-var change. If your code does direct fetch(), the migration is a rewrite.
Migrating from hand-rolled fetch to @supabase/supabase-js
Here is the pattern that shows up in codebases where nobody asked whether an SDK existed:
// Hand-rolled. Worked under legacy keys. Returns 401 under publishable keys.
async function getSessions(token, userId) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/sessions?user_id=eq.${userId}`, {
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
Multiplied across seven endpoints, with different filters and different error handling, with one of them URL-encoding filter values and the other six not, with one throwing on 4xx but swallowing 5xx, and none of them refreshing expired tokens or retrying transient failures. That was the codebase I inherited.
Here is the same call through the SDK:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
const { data, error } = await supabase
.from('sessions')
.select('*')
.eq('user_id', userId);
Three lines. Authenticated, type-safe, refresh-aware, retry-aware. Starting with supabase-js v2.102.0, PostgREST queries include built-in automatic retries for transient errors with exponential backoff. The hand-rolled wrapper had none of that.
The diff that landed when I migrated the seven wrappers:
7 files changed, 67 insertions(+), 158 deletions(-)
158 lines of hand-rolled HTTP became 67 lines of SDK calls. Net code went down. Net behaviour went up.
Why the SDK migration broke the Chrome extension
The web app side of this stack was already on @supabase/supabase-js, so the publishable-key migration didn’t touch it. The Chrome extension was on hand-rolled fetch, so every authenticated call broke. Migrating the extension to the SDK fixed the 401s and immediately broke session persistence.
Manifest V3: The current Chrome extension platform standard. Service workers replace persistent background pages. A Manifest V3 service worker has no DOM, no
window, nolocalStorage. It goes idle automatically after roughly 30 seconds of inactivity.
Symptom: the user signs in successfully. The popup closes. Thirty seconds later they click the extension icon. Logged out. Every popup open is a fresh session.
Cause: the Supabase JS SDK defaults to localStorage for session storage. Manifest V3 service workers don’t have localStorage. The SDK’s storage layer falls back to an in-memory store, which is destroyed every time the service worker goes idle.
This is the most-reported, least-documented Supabase + Chrome extension issue. The community-written guides from Tomas Pustelnik, Chethiya Kusal, and Akos Komuves are the only places it is called out clearly. The official docs give you a quickstart for Plasmo and not much else.
How to use chrome.storage.local with Supabase auth
chrome.storage.local is the right destination. Persistent, async, and available in service workers, popups, content scripts, and offscreen documents. The SDK accepts a custom storage adapter via the auth.storage option on createClient. Three method bridges and three flags:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
auth: {
storage: {
getItem: async (key) => {
const result = await chrome.storage.local.get(key);
return result[key] ?? null;
},
setItem: async (key, value) => {
await chrome.storage.local.set({ [key]: value });
},
removeItem: async (key) => {
await chrome.storage.local.remove(key);
},
},
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
What each flag does:
autoRefreshToken: truelets the SDK refresh the user’s JWT before it expires, using the refresh token stored alongside the access token. Without this, your user is bumped to a logged-out state every hour.persistSession: truetells the SDK to write to your storage adapter. Set the adapter but leave this off and the session evaporates the moment the service worker goes idle.detectSessionInUrl: falseis Chrome-extension-specific. The SDK normally looks atwindow.location.hashfor an OAuth callback. Service workers have nowindow. Leaving this on causes a runtime error in some SDK versions; turning it off is harmless if you handle OAuth viachrome.identity.launchWebAuthFlow(which you should).
On OAuth: the canonical pattern is chrome.identity.launchWebAuthFlow with PKCE flow. PKCE puts the code in the query string instead of the URL hash. chrome.identity strips the hash before returning, which kills the implicit flow entirely. Use PKCE.
Patching vs migrating
You could patch each fetch wrapper to assemble the new credential format by hand. Three or four lines per wrapper. Half an hour, maybe.
That half hour locks in fragility you inherit permanently. The SDK handles refresh, typed return values, a from(...).select(...) builder that catches PostgREST URL bugs at build time, and session storage hooks that already know the difference between localStorage, chrome.storage.local, and a custom adapter. The hand-rolled wrappers know none of that.
Every custom wrapper around a well-maintained SDK is a liability waiting for the platform to shift. The question to ask when you inherit a codebase built on direct API calls is not “what does this code do.” It is “what does this code re-implement that the platform already provides.” Find those re-implementations. Delete them. Replace with the platform primitive. The diff looks destructive. The system is sturdier afterward.
Related reading
- The disclosure that triggered this rotation: How to audit a Lovable app after the BOLA disclosure.
- The documentation system that turned a half-day rewrite into a clean checklist: How I document projects built with Claude Code.
If you are running a Supabase stack and the publishable-key migration broke something subtle, that kind of audit is work I do for SaaS founders and product teams across North America, the UK, the EU, and ANZ. Let’s talk, and I will tell you what is worth deleting before the next platform shift makes the decision for you.
Frequently asked questions
Why does Supabase's publishable key return 401?
Supabase publishable keys (sb_publishable_*) are not JWTs. Legacy anon and service_role keys were JWTs you could send as a raw apikey header to PostgREST. The new keys must go through the official @supabase/supabase-js client, which combines the publishable key with the user's session JWT in the exact Authorization header shape PostgREST expects. Send sb_publishable_* as a raw apikey header from a hand-rolled fetch wrapper and PostgREST returns 401. Putting the publishable key in the Authorization header also returns 401: Supabase reserved that header for user JWTs in the new key format.
How do I migrate from hand-rolled fetch calls to the Supabase JS SDK?
Replace each fetch wrapper with a createClient instance and the from(table).select().eq(...) builder. The SDK handles authentication headers, token refresh, automatic retries (built into PostgREST queries since supabase-js v2.102.0), error normalization, and TypeScript return types. None of those are in a hand-rolled fetch wrapper. The migration typically cuts line count by half while adding refresh and retry behaviour the original code never had.
How do I use chrome.storage.local with Supabase auth in a Manifest V3 extension?
Pass a custom storage adapter to createClient via the auth.storage option. The adapter needs three methods: getItem, setItem, removeItem, each a thin async bridge over chrome.storage.local. Set autoRefreshToken to true so the SDK refreshes the user's JWT before it expires. Set persistSession to true so the SDK writes to your adapter. Set detectSessionInUrl to false because Manifest V3 service workers have no window object. For OAuth, use chrome.identity.launchWebAuthFlow with PKCE flow: implicit flow does not work because chrome.identity strips the URL hash before returning, and Supabase's implicit flow puts the token there.
Should I patch the existing fetch wrappers or migrate to the Supabase SDK?
Migrate to the SDK. Patching looks like the smaller diff (three or four lines per wrapper) but locks in a class of fragility you inherit forever. The SDK is what the Supabase team maintains, tests, and updates when the platform changes. Hand-rolled wrappers will break the next time anything shifts in the API surface. The migration nets a smaller codebase with refresh, retry, error normalization, and type safety the original wrappers did not have.