Skip to content

API keys

An organization’s owners and admins create API keys under Organization → API keys, so that their own systems can call your API without a person’s session: a CI job that creates workspaces, a script that uploads documents, a reporting tool that exports the audit log. This page is for you, the developer. The page your customers read is apps/docs/src/content/product/reference/api-keys.md, which the kit wrote once and you own: edit it to match what your product opens to keys.

The pieces:

Piece Where
The table organization_api_key (migration 0013); packages/db/src/api-keys.ts
What a key may hold apiKeyScopes in packages/auth/src/permissions.ts, plus your productApiKeyScopes
Managing keys apiKeys.list, apiKeys.create, apiKeys.revoke; the page /app/organization/api-keys
Accepting a key .meta({ apiKey }) in the contract and orgAccess in the router
Resolving one requirePrincipal in apps/api/src/rpc/principal.ts; the prefix in api-key-token.ts

A key is starterdough_ followed by 43 random base62 characters, about 256 bits. Only its SHA-256 is stored, so neither a backup nor the database can give one back: the answer to apiKeys.create is the only place it ever appears, and the page shows it once. start, the prefix and six more characters, is kept in clear to tell keys apart in a list. The prefix is your product’s slug: bun run rename rewrites it, which gives secret scanners something to match, and which also means that every key created before a rename stops working after it.

A key belongs to the organization, not to the member who created it. It keeps working when its creator changes role or leaves, and the check that the creator’s role holds every grant runs once, when the key is created. So tell customers to revoke keys when people leave; the page shows who created each one. A ban is the exception: while a platform admin’s ban on its creator lasts, a key answers UNAUTHORIZED (“created by an account that is suspended”), because they saw its secret, and it works again when the ban ends. A key whose creator’s account was deleted keeps working. To stop every key of one organization at once, a platform admin revokes them from /admin/organizations (see Admin), which also switches off its webhooks. A key may expire after 30, 90 or 365 days, or never. Revoked and expired keys keep their row, so the audit entries made with them still name them. One organization may hold 50 live keys; apiKeys.create answers PRECONDITION_FAILED past that.

A key is granted statements, as a role is, but only these (apiKeyScopes):

Resource Actions
workspace create, read, update, delete, anyTeam
document create, read, deleteAny
job create, read, cancel
auditLog read, export
billing read

A key can never manage members, invitations, teams, API keys or webhooks, change the plan, or delete the organization: none of those statements is offered, and apiKeys.create refuses a grant outside the list with BAD_REQUEST (data.reason: 'scope'), as it does one the creator’s own role lacks. At request time a stored grant outside the list counts for nothing, so a scope a later release withdraws stops working on the keys that hold it.

In the tenant guard a key becomes a member with no user and no role (member.userId and member.role are null, member.apiKeyId names the key). Two rules follow from that:

  • A key is in no team. It sees the workspaces scoped to no team, and every team’s only with workspace: anyTeam (“Workspaces of every team” on the page), which only owners and admins can grant. A job outside every workspace is its creator’s and the anyTeam holders’, and a key is nobody’s creator, so jobs.create through a key without anyTeam must name a workspaceId.
  • A key is nobody’s author. What it creates has createdBy and uploadedBy null, and the delete rule (mayDelete) lets it delete only with deleteAny. That is why document: delete, which deletes one’s own uploads, is not a key scope: it would be a checkbox that does nothing.

auditLog: read and auditLog: export give a key, or a role, the organization’s whole log, every entry in it, but the team boundary covers what an entry says. Without workspace: anyTeam, an entry about another team’s workspace, or about a workspace that is gone, comes with metadata: null: its action, actor, time and target id stay, and the names of the workspace and its documents do not. A visibility rule of yours that narrows a key hides the same way what it hides, and a deleted workspace’s details too, anyTeam or not. Webhook endpoint URLs are in no workspace, so the entries about endpoints show them to every holder. The page says so under the audit log permissions.

Your own statements a key may hold go in productApiKeyScopes, in packages/auth/src/product-permissions.ts, next to productStatement. Name only your own resources there (bun run check refuses one of the kit’s), leave out anything that manages people, and offer deleteAny rather than delete (and guard that delete as below). The page offers them by their statement names, note:read, next to the kit’s.

Procedures Grant the key needs
organizations.usage billing: read
organizations.auditLog auditLog: read
organizations.auditLogExport auditLog: export
workspaces.list, get workspace: read
workspaces.create, update, delete workspace: create, update, delete
documents.list, get, text, downloadUrl, search document: read
documents.createUpload, complete document: create
documents.delete document: deleteAny
jobs.list, get job: read; a job on a document shows its input and result only with document: read too
jobs.create, retry job: create, and document: read for a job on a document
jobs.cancel job: cancel

Nothing else takes one: not apiKeys.*, admin.*, account.*, billing.*, system.* or contact.send; not jobs.stream, which re-checks a session every 30 seconds; and not files.*, whose uploader check needs a person (a product procedure that claims a file a key uploaded could otherwise claim anyone’s). A key gets 401 with “This procedure does not accept API keys.” there.

Both transports take a key: Authorization: Bearer <key> on /api/v1, and on /rpc through the typed client, createApiClient({ baseUrl, getToken: () => key }), which also sends the CSRF header /rpc requires.

Two lines, one in each layer, and both are required:

// packages/api-contract/src/notes.ts: the contract opens it and names the grant.
list: protectedProcedure
.meta({ apiKey: { note: ['read'] } })
.route({ method: 'GET', path: '/workspaces/{workspaceId}/notes', tags: ['notes'] })
// …
// apps/api/src/rpc/notes.ts: the router builds it on `orgAccess` instead of `authed`.
list: orgAccess.notes.list
.use(orgScoped({ note: ['read'] }))
.handler(({ context, input }) => listNotes(context.member, input.workspaceId)),

authed refuses every key before Better Auth sees it, so a procedure stays closed unless its contract names a grant and its builder is orgAccess. api-key-access.test.ts walks the router and fails for either half without the other, for a grant no key may hold, and for a key let into the areas listed above. The grant appears in the OpenAPI document as the operation’s apiKey security requirement, so the interactive reference shows it.

In a handler on orgAccess there is no context.session, and bun run check says so. Read the caller from context.member instead:

  • member.userId for createdBy and the like; it is null for a key.
  • ...auditActor(context) in recordAudit, which records the key (api_key_id) and no actor.
  • mayDelete(member.can, 'note', row.createdBy, member.userId) for deletes; member.can answers for the key’s grants as it does for a role.
  • A budget per caller keyed on member.apiKeyId ?? member.userId, as documents.search does.

The contract’s grant and the guard’s permission are both checked, so if they drift a key only gets less. Keep them the same. A delete is the one exception: its contract names deleteAny, the delete a key can hold, and its guard names no permission at all, as documents.delete’s does (orgScopedRow(noteById)), so mayDelete decides. A guard that asked for delete would refuse every key before the handler ran. api-key-access.test.ts fails for a guard that asks for more than the contract grants.

Each key may make API_KEY_RATE_LIMIT requests a minute (120 by default) before it gets 429. The counter is in-process, like the procedure limiter, which counts a key in a bucket of its own (apiKey:<id>, PROCEDURE_RATE_LIMIT_PER_MINUTE, 300 a minute) rather than against the address it calls from, so N API replicas allow N times the limit. last_used_at is written at most once a minute per key, off the request’s path.

What a key does is recorded with api_key_id set and no actor. The audit log page names it (“API key Deploys (starterdough_AbC123…)”), the CSV export has an api_key column, and organizations.auditLog returns apiKeyId and apiKey: { name, start }. Creating and revoking a key are entries of their own, api_key.create and api_key.revoke; a platform admin’s revocation names the admin as its actor and carries by: 'platform-admin'.