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 |
What a key is
Section titled “What a key is”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.
What a key may hold
Section titled “What a key may hold”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 theanyTeamholders’, and a key is nobody’s creator, sojobs.createthrough a key withoutanyTeammust name aworkspaceId. - A key is nobody’s author. What it creates has
createdByanduploadedBynull, and the delete rule (mayDelete) lets it delete only withdeleteAny. That is whydocument: 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.
Which procedures take a key
Section titled “Which procedures take a key”| 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.
Opening a procedure of yours to keys
Section titled “Opening a procedure of yours to keys”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.userIdforcreatedByand the like; it isnullfor a key....auditActor(context)inrecordAudit, which records the key (api_key_id) and no actor.mayDelete(member.can, 'note', row.createdBy, member.userId)for deletes;member.cananswers for the key’s grants as it does for a role.- A budget per caller keyed on
member.apiKeyId ?? member.userId, asdocuments.searchdoes.
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.
Rate limits
Section titled “Rate limits”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.
The audit log
Section titled “The audit log”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'.