Skip to main content

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.

The account's personal access token is not here

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 TokensCan Manage Supabase.
  • 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:
Project scope now applies here too — it did not before 2026.08.19.1

The 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:

TokenSees
all_projects: trueevery registered Supabase project — unchanged
project-scopedthe 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 name

Live 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.

Act on account.email, never the top-level account_email alone

supabase_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_activethe registration is live. false retires it and drops it from ?active=1
keepalive.enabledFilesHub 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:

  • env blocks 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 .env parser (spaces, #, $, quotes) are quoted and escaped for you (see the Laravel DB_PASSWORD above).
  • 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-rotated APP_KEY — the vault returns null for that field rather than failing the whole call, while its has flag still reports it as present. Re-enter the value in the admin to fix it.

Errors

HTTPcodeWhen
401MISSING_ACCESS_TOKEN, INVALID_ACCESS_TOKEN_FORMAT, INVALID_ACCESS_TOKEN, TOKEN_REVOKED, TOKEN_EXPIREDAuth (see Authentication)
403TOKEN_PERMISSION_DENIEDThe token lacks the can_manage_supabase scope
404NOT_FOUNDNo Supabase project matches the given id or ref
429Over 120 requests/minute