Credentials
Third-party tokens behind a credential reference, resolved by a CredentialProvider over Supabase Vault or Vercel Connect.
Blocks that call other services (a connector, an MCP server, a Slack bot)
never store a token in a table. A row stores a credential_ref jsonb that
names the credential, and a CredentialProvider from
better-supabase/credentials turns the ref into a token when the server
needs one. Revoking a connection is a provider call, not a schema change,
and a leaked row reveals no secret.
// A ref stored in a row
{ "provider": "vault", "secret": "github", "scope": "user" }| Provider | Subpath | Where the secret lives |
|---|---|---|
vaultCredentials(options) | better-supabase/credentials | Supabase Vault, through the credentials SQL module |
vercelConnectCredentials() | better-supabase/vercel-connect | Vercel Connect connectors |
Supabase Vault
pnpm better-supabase sql add credentialsThe module adds three security definer functions that only
service_role can call: credential_get(provider, name),
credential_set(provider, name, secret, description) and
credential_delete(provider, name). Secrets are stored in Vault under the
name bs:cred:<provider>:<name>. The module needs the Vault extension,
which Supabase projects have by default.
import { sqlTransport, vaultCredentials } from "better-supabase/credentials";
export const bs = createServer(betterSupabase, {
credentials: vaultCredentials({
transport: sqlTransport(postgres.asService()),
}),
});Handlers read it as ctx.credentials:
const subject = subjectFor(ctx);
if (ctx.credentials === undefined || subject === undefined)
return unauthorized();
const token = await ctx.credentials
.getToken(row.credential_ref, { subject })
.orThrow();
await fetch(url, { headers: token.headers });subjectFor(ctx) is the signed-in user (or the user behind an API key),
the app itself for a service request, or undefined for an anonymous one.
ctx.credentials is set only when createServer gets a provider. A ref with scope: "user" holds one
secret per user; reading it without a user subject fails with the hint
CREDENTIAL_SUBJECT_REQUIRED, and a missing secret is not_found with
CREDENTIAL_NOT_FOUND. token.headers is authorization: Bearer <token>
unless the ref sets header and scheme (scheme: null sends the raw
token). Tokens are cached for cacheMs (60 seconds); set and revoke
clear the cache.
Store a secret with credentials.set(ref, value, { subject }), and remove
it with credentials.revoke(ref, { subject }).
Tenant refs
A ref on a tenant's row carries that tenant: { "provider": "vault", "secret": "github", "tenant": "<organization id>" }. Build one with
tenantCredentialRef(tenant, ref). Vault stores it as
tenant/<tenant>/<secret>, apart from every other tenant's secrets and the
app's own, so a tenant admin who picks a ref can't name another tenant's
credential. The connectors, AI providers and workflow builder blocks
reject a ref whose tenant isn't the row's tenant, in SQL and before any
provider call, with forbidden and the hint CREDENTIAL_REF_FOREIGN.
They never resolve or revoke such a ref. Only the service role may store
a ref without a tenant on a tenant's row; credentialRefInTenant(ref, tenant) is the same check for your own tables.
A provider without tenant namespaces refuses a tenant ref for the app
subject. vercelConnectCredentials does this: Vercel Connect would return
the app's own installation token for every tenant, so it resolves a tenant
ref only for a user subject.
Inbound requests
A ref with inbound verifies requests the other service sends:
standard-webhooks (Standard Webhooks signatures), hmac-sha256 (a hex
HMAC in x-hub-signature-256, or signatureHeader) or shared-secret.
credentials.verifyInbound(request, ref) resolves with true when the
request is signed with the stored secret. It reads a clone, so the handler
can still read the body.
Vercel Connect
Vercel Connect holds the OAuth tokens of
connectors you install on a Vercel project. The adapter loads
@vercel/connect the first time it is used:
pnpm add @vercel/connectimport { vercelConnectCredentials } from "better-supabase/vercel-connect";
export const bs = createServer(betterSupabase, {
credentials: vercelConnectCredentials(),
});A ref names the connector: { "provider": "vercel-connect", "connector": "github", "scopes": ["repo"] }. On Vercel the adapter authenticates with
the deployment's OIDC token; elsewhere pass vercelToken. When the user
hasn't authorized the connector yet, getToken fails with forbidden and
the hint CREDENTIAL_AUTHORIZATION_REQUIRED; call
startAuthorization(ref, { subject, redirectUri }) and send the user to the URL
it returns.
Your own provider
A CredentialProvider has apiVersion: 1, a name, capabilities
(userSubjects, authorization, revoke, inbound), getToken(ref, options) and revoke(ref, options), plus startAuthorization,
completeAuthorization and verifyInbound when its capabilities say so.
Every method returns a Result, never throws for a provider error, and
puts the token only in the returned value. Run testCredentialProvider from
better-supabase/testing against it; it throws a ConformanceError that
lists every failed check:
import { testCredentialProvider } from "better-supabase/testing";
import { it } from "vitest";
it("conforms", () =>
testCredentialProvider(provider, {
ref: { provider: "mine", name: "github" },
seed: (ref, subject, value) => store.put(ref, subject, value),
}));Last updated on
Document formats
Render the API model to a standard better-supabase does not ship, such as a Postman collection or a GraphQL schema, with defineDocumentFormat and testDocumentFormat.
Add a credential provider
Write a CredentialProvider for Nango, Composio or a cloud secret manager, route refs to more than one provider, and check it with the conformance kit.