Supabase project vault
FilesHub can also act as a credential vault for your Supabase projects. Register a project once in the FilesHub admin — its API keys, JWT secret, Postgres connection details, and S3 keys — and this part of the Management API reads them back, on demand, over one authenticated call: everything a coding agent needs to wire that Supabase project into a React, Node or Laravel app.
These endpoints are read-only (list, show, reveal). Creating and editing Supabase projects stays in the FilesHub admin by design — a vault is filled by a person, and read by a machine.
A Supabase personal access token (sbp_…) belongs to the account, not to a project, and it reaches
every project that account owns. It lives on the account record instead, behind its own scope — see
Supabase account tokens. No endpoint on this page returns one, whatever scopes
your token holds.
Base URL https://fileshub.zaions.com/api/public/v1, same Authorization: Bearer fh_pat_... as the rest
of the Management API.
The can_manage_supabase scope
Supabase projects are account-wide — they belong to no FilesHub project of their own — so the primary
gate is a separate boolean on the access token: can_manage_supabase, which is off by default.
- Enable it per token in the FilesHub admin → Access Tokens → Can Manage Supabase.
- A token without it gets
403 TOKEN_PERMISSION_DENIED(not the anti-enumeration404), because the scope is a property of your own token — an honest error is more useful than pretending the resource does not exist:
2026.08.19.1The scope boolean used to be the only lever. A token deliberately restricted to one FilesHub project
still listed every Supabase project on the account and could reveal any of their database passwords and
service keys, because supabase_projects had no relation to projects to scope by.
Since 2026.08.19.1 the rule is link and filter:
| Token | Sees |
|---|---|
all_projects: true | every registered Supabase project — unchanged |
| project-scoped | the Supabase projects linked from a FilesHub project it can manage, plus every unlinked one |
An out-of-scope project answers 404, like every other out-of-scope resource on this plane — not 403,
which would confirm it exists.
Unlinked projects stay visible on purpose. Nothing was linked when this shipped, so filtering them out would have emptied the vault for every scoped token and read as an outage rather than a policy. The link is what closes the gap, and it is writable:
curl -X PATCH "$BASE/projects/my-app" \
-H "Authorization: Bearer $FH_PAT" -H 'Content-Type: application/json' \
-d '{"supabase_project_id": 8}'
That needs can_manage_supabase, and it refuses a target the token could not already see — linking widens
what a token may read, so the write is gated by exactly the same rule as the read.
{ "error": { "code": "TOKEN_PERMISSION_DENIED",
"message": "This access token cannot manage Supabase projects. Enable \"Can Manage Supabase\" on the token in Nova.",
"details": { "required_scope": "supabase_projects" } } }
required_scope is can_manage_supabase — the token's column nameLive since 2026.08.04.1 (deployed 2026-08-04). Releases before it returned the shorter label
supabase_projects, 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, "expires_at": null } }
Resolving {supabaseProject}
{supabaseProject} accepts either the numeric id or the project ref — the <ref> label of a
https://<ref>.supabase.co URL. Unknown → 404 NOT_FOUND.
GET /supabase-projects
List registered Supabase projects. Query: q (matches name / url / organization), active (true /
false, whether the registration is live), keepalive (true / false, whether FilesHub may write to
that database — added 2026.08.23.1), per_page (default 20, max 50), page. Paginated
{ "data": [...], "meta": {...} }. Each item is the safe summary — no secret values, only has flags
saying which secrets a reveal would return:
{
"data": [
{
"id": 1,
"name": "my-app",
"ref": "abcdefghijklmnop",
"url": "https://abcdefghijklmnop.supabase.co",
"organization": "My Org",
"region": "ap-southeast-1",
"account_email": "you@example.com",
"account": {
"id": 1,
"email": "you@example.com",
"label": "main free-tier account",
"has_personal_access_token": true,
"credentials_via": "/api/public/v1/supabase-accounts/1",
"requires_scope": "can_read_supabase_tokens"
},
"is_active": true,
"keepalive": { "enabled": true, "last_run_at": "2026-07-22T00:00:00+00:00", "last_status": "ok" },
"has": {
"service_key": true, "legacy_service_role_key": false, "jwt_secret": true,
"db_password": true, "db_url_direct": false, "db_url_session_pooler": true,
"db_url_transaction_pooler": false, "s3_secret_access_key": false
},
"last_revealed_at": null,
"created_at": "2026-07-18T00:00:00+00:00"
}
],
"meta": { "current_page": 1, "per_page": 20, "total": 1, "last_page": 1 }
}
Presence in has is tested without decrypting, so a secret that was stored under a rotated APP_KEY
still reports true — see the note under reveal.
account is a pointer, never a credential: it says which account owns the project, whether that account
has a personal access token stored, and which endpoint and scope would return it. It is null for a project
not yet linked to an account. See Supabase account tokens.
account.email, never the top-level account_email alonesupabase_projects.account_email is a legacy free-text column kept from before the account relation
existed, and the two have disagreed in production — one project published one address at the top level
and a different one inside account. Acting on the wrong one means fetching a personal access token for an
account that does not own the project, which answers 403 on supabase functions deploy while every
presence flag on the project reads true. That reads as a broken CLI, not as a wrong account.
Since 2026.08.19.1 the top-level account_email is derived from the relation, so the two agree by
construction, and account.legacy_account_email_conflict: true (with account.legacy_account_email) marks
a row whose stale column still says otherwise.
GET /supabase-projects/{supabaseProject}
One project: the summary above plus notes, the derived endpoints, and the non-secret config
(public keys, and the database / S3 coordinates without their secrets). Still no secret values.
{
"data": {
"id": 1, "name": "my-app", "ref": "abcdefghijklmnop", "url": "https://abcdefghijklmnop.supabase.co",
"organization": "My Org", "region": "ap-southeast-1", "account_email": "you@example.com",
"is_active": true, "keepalive": { "enabled": true, "last_run_at": "...", "last_status": "ok" },
"has": { "service_key": true, "jwt_secret": true, "db_password": true, "...": false },
"last_revealed_at": null, "created_at": "...",
"notes": "Used by my-app's web + worker.",
"endpoints": {
"api": "https://abcdefghijklmnop.supabase.co",
"rest": "https://abcdefghijklmnop.supabase.co/rest/v1",
"auth": "https://abcdefghijklmnop.supabase.co/auth/v1",
"storage": "https://abcdefghijklmnop.supabase.co/storage/v1",
"graphql": "https://abcdefghijklmnop.supabase.co/graphql/v1",
"functions": "https://abcdefghijklmnop.supabase.co/functions/v1",
"realtime": "wss://abcdefghijklmnop.supabase.co/realtime/v1",
"jwks": "https://abcdefghijklmnop.supabase.co/auth/v1/.well-known/jwks.json",
"dashboard": "https://supabase.com/dashboard/project/abcdefghijklmnop"
},
"config": {
"publishable_key": "sb_publishable_...",
"legacy_anon_key": null,
"jwt": { "signing_key_id": "b1c2…", "signing_algorithm": "ES256", "public_jwk": null },
"database": { "host": "db.abcdefghijklmnop.supabase.co", "port": 5432, "database": "postgres", "user": "postgres" },
"storage_s3": { "endpoint": null, "region": null, "access_key_id": null }
}
}
}
Endpoints are derived from the project URL, not stored, so they cannot drift. dashboard is null
when the URL has no inferable project ref (a self-hosted or custom-domain project); realtime is derived
from the URL's host, so it is present for those too and is null only when the URL has no parseable
host.
Two switches, not one (2026.08.23.1)
is_active and keepalive.enabled answer different questions, and reading them as one thing is the mistake
this section exists to prevent.
| Means | |
|---|---|
is_active | the registration is live. false retires it and drops it from ?active=1 |
keepalive.enabled | FilesHub may write to that database. The keep-alive is not a read — it inserts rows, counts them and prunes |
A client project is active: true, keepalive.enabled: false: its credentials stay readable here, and
FilesHub never touches its data. 🔴 A keepalive.last_status that never moves is not a broken
project when enabled is false — it is a project deliberately left alone, and a skip writes no run row
rather than recording a failure. Filter with ?keepalive=false to list them.
JWT signing keys (2026.08.23.1)
Supabase has replaced the single shared jwt_secret with per-project
asymmetric signing keys. config.jwt records which key
a project is on — signing_key_id (the kid), signing_algorithm (ES256 / RS256 / EdDSA / HS256)
and public_jwk.
None of the three is a secret, so the has map is unchanged: a signing key's public half is served
unauthenticated from the project's own JWKS endpoint, and putting it behind a reveal would buy nothing.
That endpoint is published as endpoints.jwks and is derived, like every other endpoint — it is the
authoritative, self-updating answer to which keys are valid right now. Verify tokens against it rather than
against a stored copy, which goes stale, silently, on the first rotation. The stored fields are there to pin
a consumer's configuration, not to replace the endpoint. All three are null until someone records them.
POST /supabase-projects/{supabaseProject}/reveal
Everything from the show response plus every stored secret plus ready-to-paste .env blocks. This is
the call that hands out live credentials, 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 project 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 credentials they
legitimately requested.
{
"data": {
"id": 1, "name": "my-app", "...": "everything from show, plus:",
"secrets": {
"api": { "secret_key": "sb_secret_...", "legacy_service_role_key": null, "jwt_secret": "super-secret-jwt" },
"database": {
"password": "the-db-password",
"url_direct": null,
"url_session_pooler": "postgresql://postgres.abcdefghijklmnop:...@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres",
"url_transaction_pooler": null
},
"storage_s3": { "secret_access_key": null }
},
"env": {
"react_vite": "VITE_SUPABASE_URL=https://abcdefghijklmnop.supabase.co\nVITE_SUPABASE_ANON_KEY=sb_publishable_...",
"node": "SUPABASE_URL=https://abcdefghijklmnop.supabase.co\nSUPABASE_ANON_KEY=sb_publishable_...\nSUPABASE_SERVICE_ROLE_KEY=sb_secret_...\nSUPABASE_JWT_SECRET=super-secret-jwt\nDATABASE_URL=postgresql://...",
"laravel": "DB_CONNECTION=pgsql\nDB_HOST=db.abcdefghijklmnop.supabase.co\nDB_PORT=5432\nDB_DATABASE=postgres\nDB_USERNAME=postgres\nDB_PASSWORD=\"the-db-password\"\nSUPABASE_URL=https://abcdefghijklmnop.supabase.co\nSUPABASE_SECRET_KEY=sb_secret_..."
}
}
}
Notes on the payload:
envblocks are built from whatever the vault holds. A line is emitted only when its value exists, and a block with no lines at all is dropped — so you never paste an empty assignment. Values that would break a.envparser (spaces,#,$, quotes) are quoted and escaped for you (see the LaravelDB_PASSWORDabove).- The Laravel block prefers the discrete DB components you entered; if those are blank it decomposes a
stored session-pooler (then direct) connection URI into
DB_*lines. - An unreadable secret comes back
null. If a stored value cannot be decrypted — for example it was saved under a since-rotatedAPP_KEY— the vault returnsnullfor that field rather than failing the whole call, while itshasflag still reports it as present. Re-enter the value in the admin to fix it.
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_manage_supabase scope |
| 404 | NOT_FOUND | No Supabase project matches the given id or ref |
| 429 | — | Over 120 requests/minute |