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.
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.
better-supabase sql add api-keys # adds tenant and access as wellA 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; the purge still deletes a
tenant's keys. Rename the keys with sql.modules.api-keys.permissions.
Managing keys
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
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:
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:
{
"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
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:
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
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:
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
| 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,
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
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:
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.
Last updated on