---
sidebar_position: 4
title: Supabase project vault
description: Read and reveal a Supabase project's full credential set — API keys, JWT secret, Postgres connection details, and S3 keys — over the FilesHub Management API, plus ready-to-paste React, Node and Laravel .env blocks.
keywords: [supabase credentials api, supabase vault, reveal supabase keys, supabase service_role key api, supabase connection string, can_manage_supabase scope, wire supabase into react node laravel, supabase project management api]
last_update:
  date: 2026-07-22
  author: Ahsan Mahmood
---

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

:::note 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](./supabase-accounts.md). 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](./overview.md).

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

:::warning 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**:

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

```bash
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.
:::

```json
{ "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" } } }
```

:::note `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`](./endpoints.md#detailsrequired_scope--match-on-both-forms).
:::

`GET /token` reports the flag so you can check before calling:

```json
{ "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:

```json
{
  "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](#post-supabase-projectssupabaseprojectreveal).

`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](./supabase-accounts.md).
:::caution 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**.

```json
{
  "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. :red_circle: **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](https://supabase.com/docs/guides/auth/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.

```json
{
  "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

| HTTP | `code` | When |
| --- | --- | --- |
| 401 | `MISSING_ACCESS_TOKEN`, `INVALID_ACCESS_TOKEN_FORMAT`, `INVALID_ACCESS_TOKEN`, `TOKEN_REVOKED`, `TOKEN_EXPIRED` | Auth (see [Authentication](./authentication.md)) |
| 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 |
