api-keys
API keys for scripts and agents, per organization, on Better Auth’s API key
plugin (@better-auth/api-key). A key belongs to the member who made it and
to the organization they had open. It acts as that member on the JSON API, in
an x-api-key header, and may be limited to fewer actions than their role.
examples/crm uses it.
- No tables of ours. Better Auth’s
apikeytable holds the hashed key; its metadata holds the organization and the limits. - The member’s role, every request. A key’s request is signed in as its
member, and
requireMemberreads their role as for a cookie. A removed member’s keys stop working at once; a demoted member’s keys can do less. Limits only narrow that. - The organization comes from the session, never from the request body: the active one when the key was made. It can’t change later.
- The audit log records a key’s writes as
api_key, with the member as the actor and the key as the client (audit).
It needs @recranet/flare/access for requireMember.
Set up
Section titled “Set up”Three seams, all the app’s own. Better Auth’s plugin is an optional peer:
pnpm add @better-auth/api-key.
auth.ts, after the organization plugin; then pnpm db:generate for the
apikey table:
import { apiKeys } from "@recranet/flare/api-keys"
plugins: [ organization({ ac: access.ac, roles: access.roles, creatorRole: "owner" }), apiKeys({ prefix: "crm_" }),]middleware/02.api-keys.ts:
import { defineMiddleware } from "void"import { apiKeySession } from "@recranet/flare/api-keys"
export default defineMiddleware(apiKeySession())lib/access.ts:
import { defineAccess } from "@recranet/flare/access"import { withApiKeys } from "@recranet/flare/api-keys"
export const access = withApiKeys(defineAccess({ task: ["list", "read", "create", "update", "delete"] }))Routes keep calling access.requireMember(c, permission), unchanged.
Making and revoking keys
Section titled “Making and revoking keys”From the browser, on Better Auth’s endpoints. The answer to create carries the key, once; show it then and never again.
await fetch("/api/auth/api-key/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Nightly import", metadata: { permissions: { contact: ["list", "import"] } } }),})await fetch("/api/auth/api-key/delete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ keyId }) })Leave out metadata.permissions for a key with the member’s whole role.
metadata.organizationId in the body is ignored; without an active
organization create is a 400. /api-key/update from the browser may rename a
key but not change its metadata (400): make a new key instead.
On the server:
| Call | What |
|---|---|
listKeys(c, { organizationId, userId? }) |
The organization’s keys, newest first, or one member’s: name, start (the first characters, crm_Ab), userId, permissions, lastRequest, expiresAt, createdAt, and id to revoke with. Never show the id |
revokeKey(c, { organizationId, id }) |
Deletes any member’s key in the organization, for owners and admins after their own requireMember. Whether one went |
requestKey(c) |
The request’s key ({ id, organizationId, permissions }), or null for a cookie |
checkKeyLimits(c, permission) |
What withApiKeys adds to requireMember, for checks of your own |
What a key may do
Section titled “What a key may do”| Request | Answer |
|---|---|
A key on the JSON API (/api/) |
Signed in as its member, in its organization; requireMember as usual |
A key on a page, /mcp or /api/auth/* |
403 API_KEY_PATH: a key can’t make keys or invite members |
| A wrong, expired or revoked key | Signed out, so the route’s 401 |
| A key made without an organization (server code) | 403 API_KEY_NO_ORGANIZATION |
| An action outside the key’s limits | 403 API_KEY_LIMITED |
| An action outside the member’s role | 403 FORBIDDEN, as for a cookie |
requireScope with ?context=admin |
The key’s organization: a platform admin’s key never reads across organizations |
apiKeySession({ include, exclude }) moves the API’s prefixes (default
["/api/"] and ["/api/auth/"]), and readKey reads keys from another
database.
Options
Section titled “Options”apiKeys(options) takes Better Auth’s own options but four, which it sets:
sessions for keys and metadata are on, and keys are user-owned and read from
x-api-key. It also defaults requireName: true and turns the plugin’s own
rate limit off (it writes the key’s row on every request): limit the API with
@recranet/flare/rate-limit, counting by user, since a key’s
request has no session cookie. prefix sets defaultPrefix, so leaked keys
can be found.
Why not organization-owned keys
Section titled “Why not organization-owned keys”Better Auth’s plugin can make a key belong to the organization
(references: "organization"), but then it signs nobody in: such a key fails
with INVALID_REFERENCE_ID_FROM_API_KEY. Nothing would pass requireMember,
and the audit log would have no actor. A key acting as its member keeps one
permission check for people and scripts, and an owner who removes a member
removes their keys’ access too.
- Wrong keys. Better Auth’s plugin throws on a bad key while Void’s auth
middleware reads the session, which would be a 500.
apiKeyscatches its errors there, so the request goes on signed out. - The organization. The create hook reads the session (a cookie) and puts
its active organization in the key’s metadata, over anything the body says,
and checks the limits’ shape (
{ resource: [actions] }). - Reading keys. The middleware reads the key’s metadata through the
DBbinding, one query per key request.listKeysfilters onjson_extract(metadata, '$.organizationId'). - Not here: keys that expire by default (pass Better Auth’s
keyExpiration), per-key rate limits, and keys on MCP (agents connect with OAuth, mcp).