HomeBlog › API key scope

Scoping service-role and admin API keys

A service-role key is a skeleton key for your database. It ignores your access policies by design, which makes where it lives more important than how long it is.

API key scope illustration: a narrowly scoped service-role key with limited access to specific resources beside an admin key with full access to every resource, the admin key ringed by a visibly larger blast radius, above an application connected to its database, services and APIs.

If you have enabled Row-Level Security, written careful policies, and tested that one customer cannot reach another's rows, there is one credential that ignores all of it.

The service-role key connects to your database as a role that is exempt from row-level policies. Not by accident or oversight, but by design, because migrations, backups and administrative tooling genuinely need to see everything. The exemption is correct. What matters is that the key never reaches anywhere it should not be.

This is the third item in the readiness guide's Data isolation section, and it is deliberately placed after the other two. Policies and authorisation checks are worth very little while a key exists that ignores them.

What the key actually grants

The scope is worth stating plainly, because "admin key" undersells it. Whoever holds a service-role key can, against every table in your database:

  • read every row belonging to every customer;
  • modify or delete any of it;
  • do so without appearing as any particular user in your application logs, because the connection is not acting as a user at all.

There is no partial exposure here. The key is not "most of your data" or "read-only". It is the database, and it is a single string.

Where these keys actually leak

In practice, the same handful of places, in roughly this order.

Bundled into the frontend

Modern build tools inline environment variables at build time, and the rules about which variables get inlined differ between frameworks. Vite exposes anything prefixed VITE_. Next.js exposes anything prefixed NEXT_PUBLIC_. Create React App exposes anything prefixed REACT_APP_.

The failure is nearly always the same: someone hits an environment variable that is undefined in the browser, adds the public prefix to make the error go away, and ships. The application starts working. The key is now in a JavaScript file served to everyone who visits.

The check takes a minute. Build your site, then search the built output:

npm run build
grep -r "service_role" dist/ 2>/dev/null
grep -rE "eyJ[A-Za-z0-9_-]{20,}" dist/ | head

The second pattern matches JWT-shaped strings, which is the form Supabase keys take. Adjust the directory for your framework: dist, build, or .next. Anything that comes back deserves attention, and you should check what it is rather than assuming it is the harmless publishable key.

You can also check from the outside, on the live site: open your site, view source, and search the loaded scripts. If a key is in there, it is being served to every visitor, and it has been for as long as that build has been live.

Committed to the repository

A .env file that was never added to .gitignore, or a key pasted into a config file "temporarily" during setup. Once committed, it is in the history permanently, and deleting the file in a later commit does not remove it. That is a large enough subject to have its own item in this series.

In a shared password manager entry that too many people can open

Less dramatic and very common. The key is stored correctly, in the right tool, in a vault shared with the whole company because that was the default when someone set it up. Six people need it; nineteen can read it.

In CI logs

A build step that prints its environment for debugging, run once during a frustrating afternoon and never removed. CI logs are often readable by everyone in the organisation, and retained for months.

Scoping down

The goal is that the number of places holding a key with full access is as small as you can make it, and that you can name every one.

Use the right key for the job. Client-side code should use the anonymous or publishable key, which is designed to be public and is constrained by your row-level policies. If a browser-side feature seems to require the service-role key, that is a signal your policies do not yet permit something they should, and the right fix is a policy rather than a more powerful key.

Create purpose-specific roles. Postgres lets you create a role with exactly the grants a particular job needs. A nightly export that reads three tables does not need a credential that can drop them. This is more work than reusing the service-role key, and it means a leak from that job is bounded rather than total.

Keep it server-side, and verify that claim. Environment variables on a server are fine. Environment variables in a build that produces browser code are not. The distinction is not the variable name, it is where the output is delivered, so check the output.

Know where each copy lives. If you cannot list the places a key exists, you cannot rotate it, and a key you cannot rotate is one you will not rotate when it matters. Keeping that list is the thing that makes rotation possible.

The same problem outside Postgres

The shape recurs across stacks, under different names. If you are not on Supabase, the equivalent is worth identifying by hand, because the marketing language rarely makes the privilege level obvious.

  • Firebase. The client SDK config is public by design and constrained by Security Rules, which are the Firebase analogue of row-level policies. The Admin SDK service account key is the dangerous one: it bypasses Security Rules completely, exactly as a service-role key bypasses RLS. It arrives as a JSON file, which makes it unusually easy to commit by accident.
  • AWS. An access key pair attached to a role with broad IAM policies is the equivalent. The thing to check is not the key but the policy attached to it, and "Action": "*" on "Resource": "*" is the pattern to look for.
  • Stripe. The publishable key is meant for the browser. The secret key is not, and restricted keys exist precisely so a background job that only needs to read charges cannot also issue refunds.
  • Your own API. If you issue API keys to customers, the same question applies in reverse: does a customer key carry more scope than that customer needs, and can you revoke one without revoking all of them?

In every case the question is the same and takes one sentence to ask: what could somebody do with this string, and where does it currently live?

Auditing what you have

Set aside twenty minutes and write the list. Not from memory, from the places keys actually live:

  1. Your hosting provider's environment variable settings, for each environment: production, staging, preview deployments. Preview environments often inherit production secrets, which is rarely intended.
  2. Your CI or deployment configuration's secret store.
  3. Your password manager, including any shared vault.
  4. Local .env files on developer machines, which you cannot inspect but can ask about.
  5. Any third-party service you connected to your database: a BI tool, a no-code automation, a data pipeline. These are the ones people forget, because the key was pasted into somebody else's interface months ago.

The output is a short table: which key, where it lives, what it can do, who can see it. That table is also what makes rotation a scheduled task rather than an archaeology project, and it is the kind of artefact a security questionnaire is actually asking for when it asks about key management.

If you find one exposed

Order matters here, and the instinct to delete first makes things worse.

  1. Rotate the key. Generate a new one and update the places that legitimately need it. Until you do, the exposed key works, and deleting the file it appeared in changes nothing.
  2. Then revoke the old one, once the new key is in place and the application is healthy.
  3. Then clean up the exposure: the repository history, the build output, the CI log.
  4. Then look at what was accessed. Database connection logs, if you keep them, will show connections you do not recognise. Whether you can answer this at all depends on logging you set up beforehand.

Rotating before cleaning feels backwards and is correct. A cleaned-up repository with a still-valid key is a cosmetic fix.

What a Trufend scan can and cannot tell you here. Trufend reads your public pages and flags recognisable secret patterns served in them, which catches a key inlined into your frontend bundle. It cannot see your repositories, your CI logs, your password manager or your server environment, so a clean result narrows the question rather than settling it. Trufend reports the external view and is not a SOC 2 assessment.

The honest summary

This item is not really about key length or algorithm choice. It is about containment: how many places hold a credential that ignores every other control you have built, and whether you could list them right now.

Teams that have done the RLS work sometimes treat this as finished business. It is the opposite: the more your policies are doing, the more the one credential that bypasses them is worth protecting.

Questions people ask about this

What is a service-role key?

A credential that authenticates as a privileged database role, intended for server-side administrative work such as migrations and background jobs. In Supabase it is the key labelled service_role; in other systems it may be a superuser connection string or an admin API token.

Why does a service-role key bypass Row-Level Security?

Because administrative tooling needs to see every row to do its job. Postgres roles with the BYPASSRLS attribute, and superusers, are exempt from row-level policies by design. The exemption is intentional, which is why controlling who holds the key matters so much.

Is it safe to use a service-role key in a serverless function?

It can be, if the function is genuinely server-side and its environment variables are not exposed to the client. The risk is frameworks that inline environment variables into the browser bundle at build time. Check the built output rather than trusting the variable name.

What should I use instead on the client?

The anonymous or publishable key, which is designed to be public and is constrained by your row-level policies. It is safe in a browser precisely because it has no special privileges.

Can Trufend detect an exposed service-role key?

Partly. Trufend reads your public pages and flags recognisable secret patterns it finds there, which catches a key inlined into a served page or script. It cannot see your repositories, your build pipeline or your server environment, so a clean result is not proof the key is contained.

Find out what your domain exposes

A free Trufend scan checks your public surface in about thirty seconds, including whether recognisable secrets appear in the pages you serve.

Run a free check