Article · Apr 23, 2026
Migrating to Supabase publishable keys broke my Chrome extension. Here is the fix.
Supabase's new sb_publishable_* keys return 401 when sent as raw apikey headers, and the JS SDK defaults to localStorage which a Manifest V3 Chrome extension cannot use. Here is the migration: deleting hand-rolled fetch wrappers, switching to @supabase/supabase-js, and the chrome.storage.local adapter that keeps sessions persistent.
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, this post is the fix. 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 Supabase’s publishable key return 401?
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 (sb_publishable_…) are not JWTs. PostgREST will accept 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 forgot the user’s JWT, PostgREST returns 401 Unauthorized.
- If you put the publishable key into the
Authorization: Bearer …header, PostgREST returns 401 Unauthorized, because it is no longer possible to use a publishable or secret key inside the Authorization header. The Supabase team called this out in the API key migration announcement: theAuthorizationheader is for the user’s JWT, not the API key.
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 in the right shape automatically. If your code is written against the SDK, the migration to publishable keys 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 an AI tool will write when nobody asks it to think about the SDK:
// 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. The 67 lines have refresh, retry, error normalization, and type safety the originals did not. 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 break anything there. The Chrome extension was on hand-rolled fetch, so the publishable-key migration broke every call. Migrating the extension to the SDK fixed the 401s on every authenticated request, and immediately broke session persistence.
Symptom: the user signs in successfully. The popup closes. The user clicks the extension icon thirty seconds later. They are logged out. Every popup open is a fresh session.
Cause: the Supabase JS SDK defaults to localStorage for session storage. Manifest V3 service workers do not have localStorage. They do not have window. The SDK’s storage layer silently falls back to an in-memory store, which is destroyed every time the service worker goes idle, which on Manifest V3 is roughly every 30 seconds of inactivity.
This is the most-reported, least-documented Supabase + Chrome extension issue. The community-written guides (Tomas Pustelnik, Chethiya Kusal on Medium, Akos Komuves) are the only places this 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. It is persistent, async, and works 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 is the entire fix:
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,
},
});
A few things worth knowing about that block:
autoRefreshToken: truelets the SDK refresh the user’s JWT before it expires, using the refresh token it stored alongside the access token. Without this, your user gets bumped to a logged-out state every hour.persistSession: trueis what tells the SDK to actually write to your storage adapter. If you set the adapter but leave this off, the session evaporates the moment the service worker goes idle.detectSessionInUrl: falseis a Chrome-extension-specific flag. By default the SDK 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 Manifest V3).
If you also use OAuth in your extension, the canonical pattern is chrome.identity.launchWebAuthFlow with the PKCE flow. PKCE puts the code in the query string instead of the URL hash, which chrome.identity strips before returning. Implicit flow does not work in extensions. Use PKCE.
Why the SDK migration was the right call
You could patch each fetch wrapper to assemble the new credential format by hand. Three or four lines per wrapper. Seven wrappers. Half an hour of work, maybe.
That half hour locks in a class of fragility you inherit forever. The SDK does not just sign requests. It handles refresh, exposes typed return values, gives you a from(...).select(...) builder that catches PostgREST URL bugs at build time, and provides session storage hooks that already understand the difference between localStorage, chrome.storage.local, and a custom adapter. The hand-rolled wrappers know none of that. They will break the next time Supabase changes anything about its API surface, and you will be writing the same migration post a second time.
The general rule: every custom wrapper around an well-maintained SDK is a liability waiting for the platform to shift. When you inherit an AI-built codebase, the question to ask first 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’s own primitive. The diff will look destructive. The system will be sturdier afterward, because the next platform shift will hit the SDK, and the SDK will absorb it for you.
Related reading
- The disclosure that triggered this rotation in the first place: How to audit a Lovable app after the BOLA disclosure.
- The four-part documentation system that turned a half-day rewrite into a clean checklist: How I document AI-built projects.
If you are running a Supabase + Lovable / Cursor / Bolt stack and the publishable-key migration broke something subtle, that audit is the kind of work I do for SaaS founders and product teams across North America, the UK and Ireland, the EU and EEA, and the ANZ region. 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's new publishable keys (sb_publishable_*) are not JWTs. The legacy anon and service_role keys were JWTs you could send as a raw apikey header to PostgREST. The new keys must arrive through the official @supabase/supabase-js client, which knows how to combine the publishable key with the user's session JWT in the Authorization header in the exact 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, because Supabase reserved that header exclusively 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 which a hand-rolled fetch wrapper has. The migration typically reduces line count by half while adding refresh and retry behaviour the original code lacked. The general rule is that every custom wrapper around a well-maintained SDK is a liability waiting for the platform to shift its API surface.
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 one 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 actually writes to your storage adapter. Set detectSessionInUrl to false because Manifest V3 service workers have no window object. For OAuth, use chrome.identity.launchWebAuthFlow with the 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 and you will be writing the same migration post a second time. The migration usually nets a smaller codebase with refresh, retry, error normalization, and type safety the original wrappers did not have.