The Supabase Personal Access Token Is Not an App Credential
A PAT authenticates your account to the Management API, not your app to its database. The four credentials, which plane each sits on, and why this app ships three.
A Supabase personal access token (PAT) authenticates you — your account, not your app — to the Management API and the tools built on it: the Supabase CLI and the MCP server. It is a control-plane credential. It creates projects, runs migrations and reads logs. It is not the key your application uses to query its own database, and it should never ship inside one.
That distinction is the whole post, because it is the one that gets people into trouble. This app runs on Supabase and carries three Supabase credentials in its code. The PAT is not one of them, deliberately. Here is what each one is for and where the line sits.
The four credentials, and which plane each lives on
| Credential | Plane | Authenticates | Lives in | In this repo |
|---|---|---|---|---|
Personal access token (sbp_...) | Control | Your Supabase account | Your laptop's keychain, CI secrets | Nowhere |
| Anon / publishable key | Data | A browser visitor, under RLS | Client bundle (public) | NEXT_PUBLIC_SUPABASE_ANON_KEY |
| Service-role key | Data | Your trusted server, bypassing RLS | Server env only | SUPABASE_SERVICE_ROLE_KEY |
| User JWT | Data | One signed-in user | A cookie | Issued by the auth flow |
Control plane means "operate the project": create it, alter its schema, read its logs, deploy a function. Data plane means "read and write rows in it". A PAT does the first and cannot usefully do the second; the anon and service-role keys do the second and cannot do the first.
The practical consequence: a leaked PAT is worse than a leaked service-role key. A service-role key is terrible — it bypasses Row Level Security on one project. A classic PAT, per Supabase's own description, carries "your account's full access… every permission, on every organization and every project you belong to today, and on every one you create or join in the future." A classic token created a year ago can touch a project you created this morning.
Where the line falls in this codebase
Three credentials appear in this app's source, in three files, and each one is a different answer to "who is asking". None of them is a PAT.
src/lib/supabase/client.ts — the browser. Anon key, shipped to every visitor, safe because RLS is what actually guards the rows:
import { createBrowserClient } from "@supabase/ssr";
/** Browser Supabase client (anon key). Use in Client Components. */
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}
src/lib/supabase/admin.ts — the trusted server path. Service-role key, RLS bypassed, and note the first line, which is the whole safety mechanism:
import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createSbClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
import "server-only" makes an import from a Client Component fail the build rather than leak at runtime. That is the pattern worth copying: do not rely on remembering which file is which, make the wrong import impossible. The longer treatment of what goes wrong past that boundary is the Supabase service role key — the short version is that with RLS bypassed, a dropped .eq() returns every user's rows instead of none.
src/lib/supabase/server.ts — Server Components and Server Actions. Anon key again, but bound to the request cookies so RLS sees the signed-in user.
The fourth credential — the PAT — is used by things that are not the app at all: the CLI that applied the eight files in supabase/migrations/, and the MCP server that inspects the schema. It lives in a keychain and a CI secret, never in .env.local beside the other two, because nothing the running application does needs it.
What the PAT is actually for: the work the app cannot do
It is easier to keep the two planes straight once you have seen what only the control plane can do. In this project the PAT's entire job was schema and inspection — work that happens at development time, from a laptop or CI, and never during a request.
supabase/migrations/ holds eight numbered SQL files, applied by the CLI, and reading their names is a decent summary of what a control-plane credential is for:
0001_commerce_init.sql -- tables + RLS enabled
0002_display_name_allowlist.sql
0003_commerce_rpcs.sql -- atomic SECURITY DEFINER functions
0004_lock_down_rate_limit_fn.sql
0005_retain_anonymized_download_audit.sql
0006_pricing_v2_slot_kinds.sql
0007_atomic_download_rate_limit.sql
0008_refund_requests.sql
Every one of those is a privileged operation: creating tables, enabling Row Level Security, defining SECURITY DEFINER functions that run with the definer's rights. None of them is something the running application should ever be able to do. That is precisely why the credential that applies them is not in the app's environment — the app holds keys that can read and write rows, and nothing that can redefine what a row is.
The same split explains the read-only half. Pulling generated TypeScript types, checking the project's advisor lints, or inspecting the schema from an editor are all Management API calls behind a PAT. Useful constantly while building; meaningless at request time.
So the test for "which credential do I need" is simply: is this something my users' traffic causes, or something I cause? Traffic takes the anon key, with RLS deciding what it sees. Your own commands take the PAT.
How to actually use one
PATs are created from your account's access tokens settings in the dashboard, and they come in two flavours.
Classic tokens carry your account's full access, as quoted above. Scoped tokens — in public alpha at the time of writing, so you may not see the option yet — carry only the organizations, projects and permissions you pick. Scoped tokens start with the prefix sbp_fc, and Supabase's guidance is unambiguous: "We recommend scoped tokens for everything, especially AI agents, automation scripts, and CI environments. If a token leaks, the blast radius stays small."
The CLI and the Management API both read the same environment variable, which is how you skip an interactive supabase login in CI:
export SUPABASE_ACCESS_TOKEN="sbp_fc..."
# In scope: a token granted Project Settings / Read can do this
curl "https://api.supabase.com/v1/projects/your-project-ref" \
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
# Out of scope: this needs the Database permission → HTTP 403
curl -i "https://api.supabase.com/v1/projects/your-project-ref/types/typescript" \
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
One subtlety that saves confusion: a scoped token's permissions only ever narrow what your account can already do. Granting a token a permission your own role lacks has no effect — the token still cannot do it. Scoping is a blast-radius tool, not a privilege-escalation one.
Locally, supabase login stores the token in your OS credential store rather than a dotfile, which is the right default. The failure mode is pasting it into .env.local "just for now", where it joins the two keys that are meant to be there and stops looking out of place.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized from api.supabase.com | Using an anon or service-role key as a PAT | They are different planes; mint a PAT |
403 on one endpoint, 200 on another | Scoped token lacks that permission | Grant the specific permission, or use a wider scope |
| CLI prompts for login inside CI | SUPABASE_ACCESS_TOKEN not set | Export it as a secret before the CLI runs |
| Granting a permission changes nothing | Your own account role lacks it | Scopes narrow, never grant — fix the role first |
permission denied for table with a PAT | Wrong credential entirely | Row access needs anon/service-role, not a PAT |
| RLS ignored unexpectedly | Service-role client used where anon belonged | Check server-only and the call site |
The second-to-last row is the one that brings people here from a search engine: a PAT authenticates the project, so reaching for it to fix a row-level permission error is a category error. If the error names a table, the answer is in RLS — new row violates row-level security policy covers that path.
FAQ
Is a personal access token the same as the anon key? No. The anon key is a data-plane key, published in your client bundle, and useless without RLS policies that permit something. A PAT is a control-plane credential tied to your account and is never published anywhere.
Can I use a PAT in my application's server code?
You can, and you should not. If your server needs to write rows, it needs the service-role key scoped behind server-only. A PAT in a deployed app means every project in your account is one leak away, including projects that do not exist yet.
Should a CI pipeline use a classic or a scoped token? Scoped, where it is available to you — that is Supabase's own recommendation for CI and for automation generally. Grant the one or two permissions the pipeline's commands actually need.
What do I do if a PAT leaks? Revoke it in the dashboard immediately; tokens are revocable individually, which is why a per-purpose token beats one shared token. Then audit what it could reach — for a classic token, assume that is everything in the account.
Templates with this wiring already done
The three-client split above — browser, cookie-bound server, server-only admin — is the part people get wrong, and getting it wrong is the difference between RLS protecting your rows and RLS being decorative. The Next.js landing page templates and Tailwind landing page templates ship with that boundary already drawn, so the build fails if an admin client ever drifts into a Client Component.
