Skip to content

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 apikey table 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 requireMember reads 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.

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.

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

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.

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. apiKeys catches 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 DB binding, one query per key request. listKeys filters on json_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).