# API keys

> Hashed API keys for tenants and users, with scopes, expiry, rotation, a rate limit per key and an apiKey caller in every server adapter.

Source: https://bettersupabase.com/docs/blocks/api-keys

The `api-keys` block issues keys your customers call your API with. A key
looks like `bs_0f3a9c2e7b1d4a68_<secret><checksum>`: a prefix, a public id, a
43-character base62 secret and the 6-character CRC-32 checksum of everything
before it. Only the SHA-256 of the secret is stored, so a database dump holds no
usable key, and the token is shown once when the key is created. The checksum
lets `verify` and secret scanners reject a mistyped or made-up key without a
database lookup; keys created before it (no checksum) still verify.

Set the prefix to your product's, such as `acme`, with `createApiKeys({ prefix })`
and `sql.modules.api-keys.options.prefix`, so scanners and support can tell your
keys apart. It is `bs` by default.

```bash
better-supabase sql add api-keys   # adds tenant and access as well
```

A key acts in one of three ways:

| Key                              | Created with                          | Acts as                                      |
| -------------------------------- | ------------------------------------- | -------------------------------------------- |
| Tenant key                       | `organizationId`                      | The tenant: `api_key_tenant()` is its id     |
| Personal key                     | `personal: true`                      | The user who created it                      |
| Personal key limited to a tenant | `personal: true` and `organizationId` | The user, only while they are still a member |

Creating a tenant key needs `api_keys.manage` in the tenant; a personal key
in a tenant needs `api_keys.own`. A personal key without a tenant acts in
every tenant its user belongs to, so it needs `api_keys.own` in each of them;
a user without any membership can still create one. The default roles give
`admin` both and `member` the second. API keys are never part of a
[data export](/docs/blocks/data-lifecycle); the purge still deletes a
tenant's keys. Rename the keys with `sql.modules.api-keys.permissions`.

## Managing keys [#managing-keys]

```ts title="app/settings/api-keys/actions.ts"
import { createApiKeys, rpcTransport } from "better-supabase/blocks/api-keys";

const keys = createApiKeys({ transport: rpcTransport(supabase) });

const { key, token } = await keys
  .create({
    name: "CI",
    organizationId,
    scopes: ["deals:read"],
    expiresAt: Temporal.Now.instant().add({ hours: 24 * 90 }),
    rateLimit: 600, // requests per minute
  })
  .orThrow();
// Show `token` once; `key` has everything else.

await keys.list(organizationId); // the tenant's keys for managers, the caller's own otherwise
await keys.rotate(key.id, { grace: Temporal.Duration.from({ hours: 1 }) });
await keys.revoke(key.id);
```

`rotate` returns a new token and keeps the old one working for the grace
period (one day by default), so a deployment can switch over. `revoke` stops
the key at once.

Every key that `create`, `list` and `rotate` return has a `state`, computed
with the database clock, so a list shows a rotated key as still working
while its grace period runs:

| `state`   | The key                                         |
| --------- | ----------------------------------------------- |
| `active`  | authenticates                                   |
| `grace`   | was rotated and authenticates until `revokedAt` |
| `revoked` | was revoked, or its grace period ended          |
| `expired` | passed its `expiresAt`                          |

A rotated key also has `successorId`, the id of the key that replaced it.

## Accepting keys [#accepting-keys]

`apiKeyResolver` reads the `x-api-key` header, or a Bearer token shaped like
a key, and turns it into an `apiKey` caller. Requests without a key fall
through to the usual JWT and cookie resolution. Verification runs as the
service role, so give the resolver a service transport:

```ts title="src/server.ts"
import { createPostgres } from "better-supabase/postgres";
import {
  apiKeyResolver,
  createApiKeys,
  sqlTransport,
} from "better-supabase/blocks/api-keys";

const postgres = createPostgres({ connectionString: env.SUPABASE_DB_URL });
const keys = createApiKeys({ transport: sqlTransport(postgres.admin) });

export const bs = createHono(betterSupabase, {
  postgres,
  auth: { resolvers: [apiKeyResolver({ keys })] },
});

app.use(
  "/api/deals/*",
  bs.middleware({ allow: ["user", "apiKey"], scopes: ["deals:read"] }),
);
```

The guard checks `scopes` against the key's scopes; `*` grants every scope.
An invalid, expired or revoked key answers 401, and a key over its rate limit
answers 429 with `Retry-After`. A personal key also answers 401 while its user
is disabled, banned in Supabase Auth (`auth.users.banned_until` in the future)
or soft-deleted, the same as the user's sign-in. Each verification counts against the key's
limit and updates `last_used_at` at most once a minute
(`sql.modules.api-keys.options.touchInterval`, in seconds).

The `apiKey` caller carries `createdAt` (a `Temporal.Instant`) and
`createdBy` (the creating user's id) when the key row has them, so a guard or
an authorization check can tell who issued the key. `toSession` returns
`createdAt` in epoch seconds, like the session's other times.

There is no JWT for an API key, so PostgREST can't run its queries. `ctx.db`
and `ctx.sql` run over direct Postgres instead (pass `postgres` to the
server), with these claims and the key's tenant as `better_supabase.tenant`:

```json
{
  "sub": "<user id, or empty for a tenant key>",
  "role": "authenticated",
  "api_key": {
    "id": "…",
    "name": "CI",
    "organization_id": "…",
    "scopes": ["deals:read"]
  }
}
```

`ctx.supabase` and `ctx.db.$client` throw for an API key caller.
`authMode` (for `withSupabase` entries) is `user` for a personal key and
`secret` for a tenant key.

### On the `@supabase/server` pipeline [#on-the-supabaseserver-pipeline]

A REST API built on `@supabase/server`'s `pipeline` takes keys with
`withApiKey` in place of `withSupabase`. It verifies the key, answers a
missing, invalid or rate-limited key with Problem Details (`problem` sets
your error format), and contributes `ctx.auth` and the `withSupabase` keys
(`jwtClaims`, `userClaims`, `authMode`) from the key's claims, so
`withPostgresClient` runs each query as the key:

```ts title="api/deals.ts"
import { pipeline } from "@supabase/middleware";
import { withPostgresClient } from "@supabase/server/middleware/postgres";
import {
  createApiKeys,
  sqlTransport,
  withApiKey,
} from "better-supabase/blocks/api-keys";
import { withBetterPostgres } from "better-supabase/server";

export default {
  fetch: pipeline(
    [
      withApiKey({ keys }),
      withPostgresClient(),
      withBetterPostgres(betterSupabase)(),
    ],
    async (_request, ctx) =>
      Response.json(await ctx.sql.deals.findMany().orThrow()),
  ),
};
```

## In RLS [#in-rls]

A personal key runs as its user, so existing policies apply. Add
`has_scope()` where a policy should also respect the key's scopes; it is
`true` for requests without a key. Wrap both functions in `(select ...)`, as
below, so Postgres calls them once per statement instead of once per row. A
tenant key has no user, so give it its own policy:

```sql
create policy deals_read_with_key on public.deals for select to authenticated
  using (
    organization_id = (select better_supabase.api_key_tenant())
    and (select better_supabase.has_scope('deals:read'))
  );
```

## SQL [#sql]

| Function                                      | Grants                          | Returns                                                   |
| --------------------------------------------- | ------------------------------- | --------------------------------------------------------- |
| `create_api_key(name, public_id, hash, ...)`  | `authenticated`, `service_role` | The key, without its hash                                 |
| `list_api_keys(tenant)`                       | `authenticated`, `service_role` | Keys the caller may see                                   |
| `revoke_api_key(key)`                         | `authenticated`, `service_role` | `true`                                                    |
| `rotate_api_key(key, public_id, hash, grace)` | `authenticated`, `service_role` | The new key                                               |
| `verify_api_key(public_id, hash)`             | `service_role`                  | `{ status: 'ok', key }`, `invalid` or `rate_limited`      |
| `has_scope(scope)`                            | everyone                        | Whether the request's key carries `scope` (or has no key) |
| `api_key_tenant()`                            | everyone                        | The tenant of the request's key, or `null`                |

Restrict the scopes keys may carry with `sql.modules.api-keys.options.scopes`
(a list); `create_api_key` then refuses any other with `API_KEY_SCOPE_UNKNOWN`.
With an [authorization provider](/docs/extending/authorization-providers),
`scopes: "catalog"` takes the list from the provider's `permissions`, so a
key's scopes are the same permission keys your guards and policies check, and
`sql add` fails when the provider lists none.

## With an authorization provider [#with-an-authorization-provider]

Queries for a key run with the `api_key` claim. A provider's SQL functions
can read a claim too, to make the key's scopes a ceiling or to treat a tenant
key as a service principal of its tenant. Pass `claim` to the resolver (or to
`apiKeyClaims`) so the request also carries the fields the provider reads:

```ts
apiKeyResolver({
  keys,
  claim: {
    name: "authz_key",
    tenant: "organization_id",
    serviceRoles: ["integration"],
  },
  allPermissions: permissionKeys,
  tenantClaim: "tenant_id",
});
```

| Field          | Default  | Meaning                                                                        |
| -------------- | -------- | ------------------------------------------------------------------------------ |
| `name`         | required | the claim; `api_key` adds the fields to the module's own claim                 |
| `scopes`       | `scopes` | the field with the key's permission keys                                       |
| `tenant`       | `tenant` | the field with a tenant key's tenant                                           |
| `roles`        | `roles`  | the field with a tenant key's roles                                            |
| `serviceRoles` | none     | the roles a tenant key holds, unless the `serviceRoles` option answers per key |

A personal key limited to a tenant also gets `tenantClaim`, the claim that
narrows the provider's functions to one tenant. The claim has no wildcard:
`*` expands to `allPermissions`, and without it a key with `*` allows nothing
there or in `has_scope()`. Under the provider access model `create_api_key`
refuses `*` (`API_KEY_SCOPE_WILDCARD`) unless `options.scopes` lists it, and
`options.scopes: "catalog"` keeps every scope a permission key the provider
lists.

A provider's server package can verify the block's keys for its own
middleware: `keys.verify(token)` resolves to `{ status: "ok", key }` with
the key's user or tenant and its scopes, `invalid` or `rate_limited`.