# Credentials

> Third-party tokens behind a credential reference, resolved by a CredentialProvider over Supabase Vault or Vercel Connect.

Source: https://bettersupabase.com/docs/extending/credentials

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.

```ts
// 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 [#supabase-vault]

```bash
pnpm better-supabase sql add credentials
```

The 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.

```ts title="src/lib/server.ts"
import { sqlTransport, vaultCredentials } from "better-supabase/credentials";

export const bs = createServer(betterSupabase, {
  credentials: vaultCredentials({
    transport: sqlTransport(postgres.asService()),
  }),
});
```

Handlers read it as `ctx.credentials`:

```ts
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 [#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 [#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]

[Vercel Connect](https://vercel.com/docs/connect) holds the OAuth tokens of
connectors you install on a Vercel project. The adapter loads
`@vercel/connect` the first time it is used:

```bash
pnpm add @vercel/connect
```

```ts title="src/lib/server.ts"
import { 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 [#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:

```ts
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),
  }));
```