Signing keys
Asymmetric keys per owner in Supabase Vault, with rotation that keeps old keys published until their tokens expire, a JWKS endpoint and a signer for JWTs and Ed25519 webhooks.
signing-keys keeps Ed25519 and ES256 keys per owner: an organization, an
app, a stream of security events. The private key lives in
Supabase Vault, the public
key in a signing_keys row, and signing runs in TypeScript with WebCrypto.
Receivers check signatures against a JWK Set you publish, so they never hold
a secret.
better-supabase sql add signing-keysimport {
createSigningKeys,
sqlTransport,
} from "better-supabase/blocks/signing-keys";Every function is service-role only. Clients can't read the table.
Create and rotate
create(owner, alg) generates a key (Ed25519 by default, or ES256),
stores the private key in Vault and makes it the owner's active key. The
kid is the RFC 7638 thumbprint of the public key. rotate(owner) does the
same with the active key's type, and the key it replaces is retired:
import {
createSigningKeys,
sqlTransport,
} from "better-supabase/blocks/signing-keys";
export const signingKeys = createSigningKeys({
transport: sqlTransport(postgres.admin),
});
const owner = { type: "organization", id: organizationId };
const created = await signingKeys.create(owner).orThrow();
// { kid: "kPrK_qmx…", alg: "Ed25519", publicKey: { kty: "OKP", … }, retired: null }| Method | What it does |
|---|---|
create(owner, alg?) | Generates a key and makes it active; the previous key is retired |
rotate(owner, alg?) | create with the active key's type |
retire(kid) | Retires a key without a replacement |
noteExp(kid, exp) | Keeps a key published until at least exp |
jwks(owner, opts?) | The owner's public keys still in use, as a JWK Set |
jwksResponse(owner) | jwks as an application/jwk-set+json response with Cache-Control |
signer(owner, opts?) | Signs with the owner's active key |
remove(owner) | Deletes every key of the owner and its Vault secrets |
purge(opts?) | Deletes retired keys that no token can still need |
An owner has at most one active key. add_signing_key takes an advisory
lock per owner, so two rotations at once leave one active key.
Sign
signer(owner) imports the active private key once and keeps it for
cacheSeconds (300). sign(bytes) returns the raw signature with its kid
and alg; signJwt(claims, header?) returns a compact JWS with alg, kid
and typ: "JWT" in the header:
const signer = signingKeys.signer(owner, { create: "Ed25519" });
const token = await signer
.signJwt({
iss: "https://app.example.com",
sub: userId,
exp: nowSeconds + 600,
})
.orThrow();Without an active key, signing fails with not_found and the hint
SIGNING_KEY_MISSING, unless create names the type to generate. An
Ed25519 key signs with alg: "EdDSA" (RFC 8037), which most verifiers read;
set ed25519Alg: "Ed25519" for the fully specified name from RFC 9864.
A numeric exp claim is noted on the key, rounded up to the hour, so a
retired key stays in the JWKS until the last token it signed has expired.
Publish the JWKS
import { signingKeys } from "@/lib/signing-keys";
export const GET = () =>
signingKeys.jwksResponse({ type: "app", id: "default" }, { maxAge: 300 });The set has the active key and every retired key whose last exp plus the
grace period is still ahead. The grace defaults to the jwksGrace option
(5 minutes), which covers clock skew and cached JWKS responses; pass
grace to jwks or purge to change it for one call:
blocks: {
"signing-keys": { options: { jwksGrace: "15 minutes" } },
},Receivers verify with any JOSE library, or with verifyCompact from this
entry, which checks the alg against the key and refuses none:
import { verifyCompact } from "better-supabase/blocks/signing-keys";
const { header, payload } = await verifyCompact(token, jwks, { typ: "JWT" });Run purge() from a daily job to delete the retired keys that dropped out
of the set. Deleting a row deletes its Vault secret through a trigger.
Ed25519 webhooks
Outgoing webhooks can sign with an owner's key
instead of shared secrets (Standard Webhooks v1a). Pass the signer to
standardWebhooksAsymmetric and give receivers the whpk_ public key:
import {
standardWebhooksAsymmetric,
webhookPublicKey,
} from "better-supabase/blocks/webhooks";
const asymmetricSigner = standardWebhooksAsymmetric({
signer: signingKeys.signer({ type: "webhooks", id: "default" }),
});
const publicKey = webhookPublicKey(created.publicKey); // "whpk_…"Events
| Type | When |
|---|---|
signing_key.created | A key was created; data has kid and alg |
signing_key.retired | A key was retired, by rotation or retire |
Both carry ownerType, ownerId and kid, and write a security audit
entry when the audit block is installed.
Last updated on
Service principals
Non-human organization members that can't sign in, hold a role through a membership, and run organization-owned workflows and service webhook endpoints.
Shared signals
An OpenID Shared Signals transmitter and receiver, with CAEP and RISC events as signed Security Event Tokens, pushed through outgoing webhooks or polled.