Supabase account tokens
A Supabase personal access token (sbp_…) belongs to an account, not to a project. It drives the
Supabase Management API and supabase login --token, so one token reaches every project that account
owns — including projects that do not exist yet.
That is why it is not part of the Supabase project vault. It lives on a separate account record, entered once for all the projects underneath it, and it is read back through the endpoints on this page behind a scope of its own.
These endpoints are read-only (list, show, reveal). Creating and editing accounts stays in the FilesHub admin by design — a vault is filled by a person, and read by a machine.
Base URL https://fileshub.zaions.com/api/public/v1, same Authorization: Bearer fh_pat_... as the rest
of the Management API.
The can_read_supabase_tokens scope
This is a fifth, independent boolean on the access token, off by default — and the important part is what does not grant it:
can_manage_supabasedoes not implycan_read_supabase_tokens. A token holding onlycan_manage_supabasereceives403from every endpoint on this page.
Since 2026.08.19.1 a project-scoped token sees only the accounts that own a Supabase project it can see,
plus accounts that own none; an out-of-scope account answers 404. That is a second gate. It changes
nothing about the first: can_read_supabase_tokens is still required for every endpoint on this page,
because a personal access token reaches a whole supabase.com account — including projects that do not exist
yet. An all_projects token is unaffected.
The two scopes have different blast radii, so they are different switches:
| Scope | Reaches |
|---|---|
can_manage_supabase | One project's credentials — its secret key, JWT secret, database password, S3 key |
can_read_supabase_tokens | An account's PAT: every project that account owns, present and future, with authority to create and delete them |
Enable it per token in the FilesHub admin → Access Tokens → Can Read Supabase Tokens. A token without
it gets 403 TOKEN_PERMISSION_DENIED (not the anti-enumeration 404), because the scope is a property
of your own token — an honest error is more useful than pretending the resource does not exist:
{ "error": { "code": "TOKEN_PERMISSION_DENIED",
"message": "This access token cannot read Supabase personal access tokens. Enable \"Can Read Supabase Tokens\" on the token in Nova. Note that \"Can Manage Supabase\" does not grant this — a PAT reaches the whole account, so it is a separate switch.",
"details": { "required_scope": "supabase_tokens" } } }
required_scope is can_read_supabase_tokens — the token's column nameLive since 2026.08.04.1 (deployed 2026-08-04). Releases before it returned the shorter label
supabase_tokens, which matched no column, so an older client may still match on that — accepting both
forms costs nothing. See
details.required_scope.
GET /token reports the flag so you can check before calling:
{ "data": { "name": "...", "all_projects": true, "can_manage_supabase": true, "can_read_supabase_tokens": false } }
Resolving {account}
{account} accepts either the numeric id or the account's login email. The email is matched
case-insensitively, so paste it exactly as it appears in the Supabase dashboard. Unknown → 404 NOT_FOUND.
GET /supabase-accounts
List registered Supabase accounts. Query: q (matches email / label / organization), active (true /
false), per_page (default 20, max 50), page. Paginated { "data": [...], "meta": {...} }. Each item
is the safe summary — never the token, only a has flag saying whether a reveal would return one:
{
"data": [
{
"id": 1,
"email": "you@example.com",
"label": "main free-tier account",
"organization": "My Org",
"is_active": true,
"has": { "personal_access_token": true },
"projects_count": 2,
"last_revealed_at": null,
"created_at": "2026-08-01T00:00:00+00:00"
}
],
"meta": { "current_page": 1, "per_page": 20, "total": 1, "last_page": 1 }
}
projects_count is what makes the blast radius visible before you ask for the token: it is the number of
registered Supabase projects a reveal of this account would reach.
GET /supabase-accounts/{account}
One account: the summary above plus notes, the account's dashboard pages, and the projects
registered under it. Still no token value.
{
"data": {
"id": 1, "email": "you@example.com", "label": "main free-tier account",
"organization": "My Org", "is_active": true,
"has": { "personal_access_token": true },
"projects_count": 2, "last_revealed_at": null, "created_at": "...",
"notes": "Free tier — two projects, keep-alive on both.",
"dashboard": {
"account": "https://supabase.com/dashboard/account/me",
"tokens": "https://supabase.com/dashboard/account/tokens",
"organizations": "https://supabase.com/dashboard/org"
},
"projects": [
{
"id": 1, "name": "my-app", "ref": "abcdefghijklmnop",
"url": "https://abcdefghijklmnop.supabase.co", "is_active": true,
"credentials_via": "/api/public/v1/supabase-projects/1"
}
]
}
}
credentials_via points at each project's own vault entry, which is a different scope
(can_manage_supabase) — the account endpoints never return a project's keys, and the project endpoints
never return the account's token.
POST /supabase-accounts/{account}/reveal
Everything from the show response plus the personal access token plus the CLI env line. This is the
call that hands out a live account-wide credential, so it is a POST and it is recorded (best-effort:
token name, IP, user-agent and which field names were revealed — never a value — with last_revealed_at
stamped on the account and every reveal listed in the FilesHub admin). The audit write is best-effort by
design: if it fails it is logged, but it never blocks the caller from receiving the credential they
legitimately requested.
{
"data": {
"id": 1, "email": "you@example.com", "...": "everything from show, plus:",
"secrets": { "personal_access_token": "sbp_..." },
"env": { "cli": "SUPABASE_ACCESS_TOKEN=sbp_..." }
}
}
Notes on the payload:
env.cliis the variable the Supabase CLI reads. Export it andsupabase link,supabase db pushandsupabase functions deploywork without an interactivesupabase login. It is the only env block here, because an account-wide token belongs in no application's.env.- The block is emitted only when a token is stored, so you never paste an empty assignment. Values that
would break a
.envparser are quoted and escaped for you. - An unreadable token comes back
null. If the stored value cannot be decrypted — for example it was saved under a since-rotatedAPP_KEY— the vault returnsnullrather than failing the whole call, while itshasflag still reports it as present. Re-enter the value in the admin to fix it. - Supabase shows a PAT once, at creation. If the vault's copy is wrong or missing, it cannot be
recovered from Supabase — generate a new token at the
dashboard.tokensURL above and store that.
Errors
| HTTP | code | When |
|---|---|---|
| 401 | MISSING_ACCESS_TOKEN, INVALID_ACCESS_TOKEN_FORMAT, INVALID_ACCESS_TOKEN, TOKEN_REVOKED, TOKEN_EXPIRED | Auth (see Authentication) |
| 403 | TOKEN_PERMISSION_DENIED | The token lacks the can_read_supabase_tokens scope — including when it holds can_manage_supabase |
| 404 | NOT_FOUND | No Supabase account matches the given id or email |
| 429 | — | Over 120 requests/minute |