# Environment API keys

Each environment has its own keys. Apps pass them to `supabase-js` (or any
Supabase client) when they call the environment's `/auth/v1`, `/rest/v1`,
`/storage/v1`, `/realtime/v1` and `/functions/v1` endpoints. They are different
from the [access tokens and organization API keys](https://docs.scribase.com/docs/api/tokens.md) that call
the `/v1` control API.

| Key | Format | Who may hold it |
|---|---|---|
| `anon` | HS256 JWT, role `anon` | Anyone. Safe in an app bundle; row-level security decides what it can read |
| `publishable_key` | `sb_publishable_...` | Same as `anon`. The new-style opaque form |
| `service_role` | HS256 JWT, role `service_role` | Servers only. Bypasses row-level security |
| `secret_key` | `sb_secret_...` | Servers only. The opaque form of `service_role` |

All four derive from one JWT secret per environment. The JWTs have a fixed
issue time and a ten-year expiry, so they stay the same until the secret is
rotated, like Supabase project keys. The gateway (or, on the single box, the
project router) swaps an opaque key for its JWT on the way in, so current
`supabase-js` versions work with either style. An opaque key the environment
did not issue gets `401`.

## Where to find them

- **Console**: the environment's **API keys** page.
- **CLI**: [`scribase apps switch`](https://docs.scribase.com/docs/cli/apps.md) prints the URL and the
  `anon` or publishable key for an app. It never prints the server keys.
- **API**: the route below.

## GET .../environments/{env}/api-keys

`GET /v1/organizations/{org}/projects/{project}/environments/{env}/api-keys`

```json
{
  "organization_id": "acme",
  "project_id": "my-app",
  "environment_id": "production",
  "api_url": "https://<label>.<domain>",
  "anon_key": "eyJ...",
  "jwt_secret_version": 1,
  "jwt_secret_source": "control_plane",
  "publishable_key": "sb_publishable_...",
  "service_role_key": "eyJ...",
  "secret_key": "sb_secret_..."
}
```

- `service_role_key` and `secret_key` are `null` for **viewers**.
- `api_url` is `null` when the control plane has no public gateway URL
  configured.
- `?reveal=jwt_secret` adds the raw `jwt_secret`. It mints any token for the
  environment, so it needs the owner or admin role and, on an account with an
  authenticator app, a fresh MFA code.

```sh
curl "$SCRIBASE_API_URL/v1/organizations/acme/projects/my-app/environments/production/api-keys" \
  -H "Authorization: Bearer $SCRIBASE_ACCESS_TOKEN"
```

## POST .../environments/{env}/api-keys/rotate

`POST /v1/organizations/{org}/projects/{project}/environments/{env}/api-keys/rotate`

Replaces the environment's JWT secret. This **invalidates the old `anon`,
`service_role`, publishable and secret keys and every session token** the
environment issued, so every client needs the new keys and every user signs in
again. The response has the same shape as `GET`, with the new keys and a higher
`jwt_secret_version`. The control plane then publishes the new secret to the
environment's services and rolls them out.

- Needs the owner or admin role and, on an account with an authenticator app, a
  fresh MFA code.
- A personal access token cannot answer an MFA challenge, so on such accounts
  revealing and rotating are console-only. CI can still read the `anon` and
  `service_role` keys, which are never MFA-gated, or use a service-account
  machine token.
- `409 jwt_secret_operator_managed`: the deployment signs every environment
  with one operator-managed secret (`SCRIBASE_TENANT_JWT_SECRET`). Rotate it
  there instead.

## Keys after an import

`scribase import supabase` never copies the source project's keys or JWT
secret. The environment keeps its own keys, so the two projects never trust
each other's tokens. Users keep their ids and passwords and sign in once more.
