GenerateSaaS

API Keys

Issue per-user API keys for programmatic access, authenticated by the x-api-key header and rate-limited from the owner's plan.

API keys let users authenticate to the backend programmatically instead of with a session cookie, gated by config.apiKeys.enabled (packages/config/src/index.ts, on by default). Enabled, Better Auth's apiKey plugin registers, Settings → Developers issues and revokes keys, and a request carrying a valid x-api-key header acts as the key's owner within the key's scopes; disabled, every /api-keys route returns 400.

How a key authenticates

The plugin runs with enableSessionForAPIKeys: true (packages/auth/src/config.ts), so auth.api.getSession({ headers }) inspects x-api-key, looks up the key, and returns the owner's session. authGuard then accepts the key on the REST routes; what else it reaches depends on its scopes.

curl https://app.example.com/api/billing/usage \
  -H "x-api-key: key_xxxxxxxxxxxx..."

Keys are stored hashed in the apikeys table; the plaintext value is returned once at creation and never again.

Configuration

config.apiKeys is a discriminated union: { enabled: false } or { enabled: true; prefix?; maxKeysPerUser?; maxCreatesPerDay? }. There are no environment variables - keys live in the database.

KeyTypeDefaultDescription
enabledbooleantrueMaster flag; false unregisters the plugin and disables every key route.
prefixstring?"key_"Prepended to generated keys (include the underscore). The first 12 characters are stored as start for display.
maxKeysPerUsernumber?10The most keys one user may hold. A create past it answers 400 with code API_KEY_LIMIT, held under parallel creates too.
maxCreatesPerDaynumber?20The most keys one user may create per UTC day, revoked keys included, so revoke-and-recreate cannot mint fresh request quotas. A create past it answers 429 with code API_KEY_DAILY_LIMIT. Counted in Redis and fails closed: a cache outage answers 503.
packages/config/src/index.ts
apiKeys: {
  enabled: true,
  prefix: "key_",
  maxKeysPerUser: 10,
  maxCreatesPerDay: 20
}

Scopes

Every key reaches the authGuard REST routes. Anything more is a scope ticked when the key is created, stored as the plugin's permissions map ({ ai: ["access"] }) and defined in packages/auth/src/api-key-scopes.ts.

SurfaceUnscoped keyNeeds
authGuard routes (/billing reads, /projects, ...)Allowed-
Every billing write (any /billing/* method but GET/HEAD/OPTIONS: checkouts, PUT /billing/auto-topup, keep-plan), and the Stripe reads GET /billing/subscription/switch-preview and GET /billing/subscription/pending403Never - signed-in session only (refuseApiKeyWrites, router-wide; refuseApiKey on the two reads)
Push writes (every /push/* method but GET/HEAD/OPTIONS: devices, preferences)403Never - a device a key registered would outlive the key
/ai/* reads, chat, capability runs and automations403ai scope (Allow AI access, shown while config.ai.enabled)
The account's AI configuration and stored chats: every /ai/keys, /ai/integrations and /ai/threads change (rename, append, title, delete), PUT /ai/settings403Never, even with the ai scope - a provider key's custom base URL would receive every later chat, and an append's replaces deletes a chat. Reading threads and chatting through POST /ai/chat stay open
Integration sign-ins (/ai/integrations/oauth/:provider/*)403Never - connecting an account needs a signed-in session
/api/mcpOAuth 401mcp scope - see MCP access
adminGuard and orgAdminGuard routes403Never - signed-in session only
Better Auth's own /auth/* endpoints403Never, except get-session, api-key/get, api-key/list, organization/list, organization/get-active-member

To scope another router, mount requireApiKeyScope("<scope>") (@repo/auth/guards) on it and add the scope to API_KEY_SCOPES. To keep a router's changes to signed-in people, mount refuseApiKeyWrites(error) on it (reads stay open), or put refuseApiKey(error) on one route. packages/api/tests/ai/ai-api-key-policy.test.ts lists every /ai/* route with its key rule, so a new AI route fails until it is classified there.

A key with the ai scope may create, edit, delete and run automations (/ai/automations/*), and so may an MCP client. A saved automation keeps running on its schedule after the key is revoked: revoke a leaked key and review the automations list.

A key's session is minted per request, so it always counts as "fresh". That is why account endpoints (passkeys, linked logins, email, sessions, deletion) and the admin plugin refuse keys outright: a leaked key must never become a lasting credential or an administrator.

Endpoints

RouteGuardAction
GET /api-keysauthGuardList the caller's own keys (metadata only, no secret)
POST /api-keysauthGuardCreate a key ({ name, ai?, mcp? }); returns the plaintext key once. ai: true / mcp: true answer 400 while AI / the MCP server is off
DELETE /api-keys/:idauthGuardRevoke one of the caller's keys
GET /api-keys (admin)adminGuardList every user's keys, paginated, enriched with owner name/email
DELETE /api-keys/:id (admin)adminGuardRevoke any key

Create and revoke are written to the audit log (API_KEY_CREATED, API_KEY_REVOKED). See API Layer for how these mount and Authorization for the guards.

MCP access

A key opens the external agent MCP server only when it was created with Allow MCP access ticked (shown while config.mcpServer.enabled is true). That stores the permission { mcp: ["access"] } on the key, and the list marks such keys MCP.

Refused on /api/mcp with the OAuth 401Why
A key without the MCP permissionREST access never implies agent access - the MCP server hands out every chat tool, including credit-spending ones
A revoked, disabled, expired or rate-limited keyChecked by the apiKey plugin, the same as on every route
A key whose owner is banned or deletedThe session would act as an account that may not act

The agent acts as the key's owner in their personal scope, exactly as an OAuth token does. Deleting an account deletes its keys.

Keys are created, changed and revoked only from a signed-in session: POST and DELETE /api-keys, and Better Auth's own /api-key/create, /update and /delete, answer 403 to a request carrying x-api-key (one guard, @repo/auth/api-key-management), so a REST-only key can never mint itself a key with MCP access. Listing keys stays open. Better Auth's /api-key/create and /delete also answer 403 to every HTTP caller: keys are minted and revoked only through /api-keys, which enforces maxKeysPerUser and writes the audit log.

Rate limits

Each key is stamped with a request quota at creation time, derived from the plan of the user who owns it. The quota is per key, so maxKeysPerUser bounds a user's total at that many times it. packages/api/src/routes/internal/api-keys.ts calls resolveApiKeyRateLimitPlan (@repo/payments) when minting the key, then takes that plan's apiRateLimit from packages/config/src/pricing.ts, falling back to pricingConfig.defaultApiRateLimit (100 requests / day).

The quota is baked in at creation - changing a user's plan does not retroactively re-limit their existing keys. Have users rotate keys after an upgrade to pick up the new ceiling.

Frequently asked questions

Can a key act on behalf of an organization? No. Better Auth supports only user-owned keys, so a key authenticates as its owner. Metered work still bills whoever funds the resulting session - see Organizations.

Why doesn't a member get their workspace owner's quota? Because a key outlives the membership that would have granted it. Sourcing the limit from the workspace owner would let someone join a paid workspace, mint a key, leave, and keep that throughput forever.

I lost a key - can I recover it? No. Only the hash is stored. Revoke it and issue a new one from Settings → Developers.

How is this different from the Content API key? X-Content-Api-Key is a single shared secret (the CONTENT_API_KEY env var) guarding the public /v1/content route. These keys are per-user, database-backed, and authenticate as the owning user. See API Layer.

On this page