GUIDES
  • 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.

claude — ~/acme-admin
Write a script that lists the project's users.
Listing users goes through supabase.auth.admin, which needs a secret key, so this runs as a server script and never in the browser. I'll ask you for SUPABASE_URL and SUPABASE_SECRET_KEY with offprompt.
offprompt - collect_secret (MCP)
⎿
file: .env.localkeys: SUPABASE_URL SUPABASE_SECRET_KEY Opened http://127.0.0.1:4123 in your browser. Waiting for you to write them…
✻Waiting on offprompt… (esc to interrupt)
offprompt127.0.0.1:4123
Claude Code asks offprompt for the project URL and a secret key, and offprompt's page for that request opens in your browser. The URL shows in the clear, and the key's Get it link opens that project's API keys.

Supabase's keys

  • Publishable key, sb_publishable_…, and its legacy form, the anon key. 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, the service_role key. 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

  1. 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-admin
    Saving 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.sql
    What to ask of the agent when Row Level Security stops a query: a policy for that table, with the publishable key still in use.
  2. 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 legacy service_role key cannot be revoked on its own like that.

  3. 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 on 127.0.0.1. When the agent knows the project ref, the 20 letters before .supabase.co in 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 legacy service_role JWT for an older project. offprompt writes both values to .env.local, and the agent gets their names and a four-emoji fingerprint.

  4. 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-js
    list-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.mjs
    claude — ~/acme-admin
    Bash(node --env-file=.env.local list-users.mjs)
    ⎿
    42 users
    The agent runs the script on your machine. The secret key goes from .env.local to Supabase, and never through the session.
  5. 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 in NEXT_PUBLIC_SUPABASE_URL and NEXT_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

Try it

In a project's folder, run the command below, then ask the agent for something that needs a key.

npx offprompt init

More guides