- Supabase
Use Supabase with a coding agent without leaking the service_role key
Supabase's service_role key, and the secret keys replacing it, bypass every Row Level Security policy: whoever holds one can read and write all the project's data. Keep it out of the agent's chat and out of any code the browser loads. Let the agent ask for it with offprompt, which writes it to .env.local as SUPABASE_SECRET_KEY, and use it only in server code.
Supabase's keys
- Publishable key,
sb_publishable_…, and its legacy form, theanonkey. Safe in a web page, an app or source code: it can do only what your Row Level Security policies allow. - Secret key,
sb_secret_…, and its legacy form, theservice_rolekey. Full access to the project's data, past every policy. For servers, Edge Functions and other backend code only.
The legacy keys are long JWTs that start with eyJ. Supabase is retiring them by the end of 2026, and projects made since November 2025 don't have them. They keep working beside the new keys until you turn them off, so an older project can move one component at a time.
Why service_role must never be in the chat or the browser
In the chat, the key goes to the model provider with every later request, and stays in the session's transcript. Supabase's own docs list chat among the ways a secret key must never travel.
In client code, it is in the JavaScript every visitor downloads. Anyone can read it from there and use it from their own machine, with every policy bypassed. A variable named NEXT_PUBLIC_ or VITE_ is put in the browser's bundle on purpose, so a secret key must never have one of those names. A new secret key refuses requests from a browser, matched on its user agent, but that does nothing to stop someone using a leaked key from a script.
A legacy key is hard to take back. The legacy keys are signed with the project's JWT secret, so retiring a leaked service_role key turns off anon with it. A new secret key can be deleted on its own, at once.
Steps
Check that the code needs a secret key at all
Most of an app's code should use the publishable key, with Row Level Security deciding what each user may read and write. A secret key is for code that has to act beyond any one user: an admin script, a webhook handler, a scheduled job, or a call to
supabase.auth.admin. If the agent reaches for the secret key because a query came back empty or failed with a row-level security error, the fix is a policy, not the key.claude — ~/acme-adminSaving a profile fails. Fix it.The insert fails with “new row violates row-level security policy for table profiles”. Switching to the secret key would skip every policy on every table, so I'll add a policy that lets a signed-in user insert their own row instead.Write(supabase/migrations/20261031120000_profiles_insert_own.sql)⎿Wrote 4 lines to supabase/migrations/20261031120000_profiles_insert_own.sqlWhat to ask of the agent when Row Level Security stops a query: a policy for that table, with the publishable key still in use. Make a secret key for this one use
In the Dashboard, open the project’s Settings, then API Keys, and create a new secret key named after the component that will use it, such as
admin-scripts. Supabase suggests one secret key per backend component, so a leak means deleting one key while the others keep working. A legacyservice_rolekey cannot be revoked on its own like that.Let the agent ask for SUPABASE_SECRET_KEY
Ask for the task: “Write a script that lists the project's users.” The agent calls offprompt's
collect_secret, and a page opens in your browser on127.0.0.1. When the agent knows the project ref, the 20 letters before.supabase.coin the project's URL, the key's link on the page opens that project's API keys. The URL itself is not a secret, so the page shows it in the clear.collect_secret { "secrets": [ { "name": "SUPABASE_URL", "format": "url", "secret": false }, { "name": "SUPABASE_SECRET_KEY", "provider": "supabase", "link": { "project": "abcdefghijklmnopqrst" } } ], "reason": "The user list comes from **Supabase**'s admin API, which needs a secret key.", "sink": { "kind": "dotenv", "path": ".env.local" } }The key field takes an
sb_secret_key, or a legacyservice_roleJWT for an older project. offprompt writes both values to.env.local, and the agent gets their names and a four-emoji fingerprint.Use the key only in server code
A Node script reads both values from the environment, and runs with the file loaded:
npm install @supabase/supabase-jslist-users.mjs import { createClient } from '@supabase/supabase-js' // A server-side client: no user session to keep or refresh. const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SECRET_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }) // auth.admin needs a secret key, and so only ever runs on a server. const { data, error } = await supabase.auth.admin.listUsers() if (error) { console.error(error.message) process.exit(1) } console.log(`${data.users.length} users`)node --env-file=.env.local list-users.mjsclaude — ~/acme-adminBash(node --env-file=.env.local list-users.mjs)⎿42 usersThe agent runs the script on your machine. The secret key goes from .env.local to Supabase, and never through the session. In Next.js, keep the client in a server-only module
Put the admin client in a module of its own that imports
server-only, from the npm package of that name: the build fails if a client component imports it, so the key cannot reach the browser by accident.lib/supabase-admin.ts import 'server-only' import { createClient } from '@supabase/supabase-js' export const supabaseAdmin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { autoRefreshToken: false, persistSession: false }, })Name the variables without
NEXT_PUBLIC_, which would put the key in the JavaScript sent to every visitor. The URL and publishable key the browser uses go inNEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, as in Supabase’s own examples.
If the key has leaked
Make a new secret key, have the agent ask for SUPABASE_SECRET_KEY again with offprompt, which replaces the value in .env.local, and delete the old key once nothing uses it. Deleting a secret key cannot be undone. Supabase also revokes secret keys it finds in public GitHub repositories, and tells the project's owner. For a leaked legacy service_role key, move to secret keys and turn the legacy keys off; Supabase's guide to rotating leaked keys has the order to do it in.
Why not paste the key into the chat
Claude asked for my API key. Is it safe to paste it? explains where a key pasted into an agent's chat ends up, and how offprompt keeps the agent out of .env.local once the key is written.
Sources
What the provider's own docs say, as of 31 October 2026.
- Supabase: Understanding API keys
- Supabase: Migrating to the new API keys
- Supabase: Rotating leaked API keys
- supabase-js: Auth admin
- supabase-js: listUsers
- Supabase: API keys announcement and timeline
Something here out of date? Open an issue on GitHub.
Try it
In a project's folder, run the command below, then ask the agent for something that needs a key.
npx offprompt init