API Keys
API key authentication for programmatic access to your Quickback API.
API keys provide programmatic access for CI/CD pipelines, scripts, and server-to-server communication.
Creating API Keys
Create API keys from Account UI (/api-keys, linked from profile). Each key is owned by you and scoped to an organization.
Usage
QUICKBACK_API_KEY=your_api_key quickback buildThe compiler validates the key by calling the Quickback API's Better Auth GET /auth/v1/get-session with it. See Authentication for details.
What a key can reach
Send a key as x-api-key: <key>, or — for keys that carry the project prefix (every key created since prefixes were added) — as Authorization: Bearer <key>. Older unprefixed keys work only through x-api-key. Either way a key authenticates two things:
- Your API (
/api/*) — the auth middleware verifies the key and resolves its owner's role in the organization named by the key'smetadata.organizationId. GET /auth/v1/get-session— the one Better Auth endpoint where a key stands in for its owner's session (the key-validation "whoami").
Every other Better Auth endpoint ignores the header and answers as for an unauthenticated caller: a key cannot mint an identity JWT (GET /auth/v1/token), list sessions, create or manage keys, change the user, register a passkey, or act on organizations. Those need a real session.
The generated MCP endpoint (POST /mcp) is bearer-only: it forwards Authorization, not x-api-key, to the routes its tools call. So an MCP client uses a prefixed key as Authorization: Bearer <key>. A scoped key may open /mcp (and list tools); each tool call re-enters the REST route it wraps, where the key's scopes are checked — a tasks:read key can call the list-tasks tool but gets 403 ACCESS_KEY_SCOPE_REQUIRED from the create-task tool.
The prefix is what makes the Bearer form unambiguous: plugins.apiKey.prefix must end in _ or - (Better Auth session tokens are alphanumeric, so they can never start with one) and must not start with eyJ (a JWT).
Scopes
By default a key carries its owner's full role in the organization it names. Give it metadata.scopes to narrow that — for an agent, a CI job, or an integration that only needs part of the API:
curl -X POST https://api.example.com/auth/v1/api-key/create \
-H "Cookie: ${SESSION_COOKIE}" -H "Content-Type: application/json" \
-d '{"name":"reporting agent","metadata":{"organizationId":"org_123","scopes":["customers:read","deals:read"]}}'The Account UI has a Scopes field for the same thing. The vocabulary is closed:
| Scope | Admits |
|---|---|
read | GET / HEAD / OPTIONS on every route under /api/v1 |
write | Every other method under /api/v1 — create, update, delete, batch, and non-GET actions |
<resource>:read | Reads under /api/v1/<resource>/ — list, get, views, GET actions |
<resource>:write | Writes under /api/v1/<resource>/ — including actions mounted there |
<resource> is the first path segment of a generated resource route (customers for /api/v1/customers). The method decides read vs write, so a GET action is a read. write does not imply read; list both if the key needs both. Routes outside a resource mount — area/namespace routes (/api/v1/<scope>/:id/…), root-mounted standalone actions, /api/v1/schema — are reachable only through the global read / write.
Scopes narrow, they never widen. The auth middleware checks the scope first; the route's own Access and Firewall then run with the owner's org role exactly as before. A customers:write key held by a member still cannot delete what crud.delete.access reserves for admin.
Outside /api/v1, a scoped key is refused. Storage, realtime tickets, webhooks, FGA, and public discovery routes answer a scoped key with 403. /auth/v1/* is allowed — there the key is only the get-session whoami. Use an unscoped key or a session for those surfaces.
A denied request is a standard Problem Details 403:
{
"type": "https://quickback.dev/problems/access-key-scope-required",
"title": "Access key scope required",
"status": 403,
"code": "ACCESS_KEY_SCOPE_REQUIRED",
"detail": "API key scope does not allow this request",
"layer": "access",
"details": { "required": ["customers:write"], "current": ["customers:read"] },
"hint": "Create a key with the \"customers:write\" scope (or omit scopes for a full-access key)"
}Validation
Scopes are checked when a key is created or updated (POST /auth/v1/api-key/create, /api-key/update). An unknown resource, an unknown verb, an empty list, or a non-array value fails with 400 and code: "INVALID_API_KEY_SCOPES"; nothing is normalized. A scope naming a resource that was later removed simply matches nothing. Stored scopes that are not an array of strings fail the key closed (401 AUTH_INVALID_TOKEN, details.reason: "malformed_scopes").
Existing keys
A key with no scopes in its metadata is a full-role key, so every key created before scopes existed keeps working unchanged. Scopes live in metadata, which the owner can change through /api-key/update; replacing the metadata without scopes turns the key back into a full-role key. That is the owner's call — they can always create a full-role key anyway. Scopes protect against a leaked key, not against its owner.
Scopes are stored in metadata rather than Better Auth's permissions field because permissions is server-only: the Account UI and other session clients could not set it.
Key prefix
New keys start with a display prefix so they are recognisable in logs and secret scanners. It defaults to the project name, slugged (my-app → my_app_…). Override it with the object form of the plugin:
auth: defineAuth("better-auth", {
plugins: {
apiKey: { prefix: "acme_live_" },
},
}),The prefix must be 1–32 characters of A-Z a-z 0-9 _ -; anything else fails the compile. It is display-only — Better Auth hashes the whole key — so keys created before the prefix existed keep verifying.
Banned owners
When the admin plugin bans a user, every API key they own stops working: the API answers 401 AUTH_INVALID_TOKEN with details.reason: "owner_banned", and GET /auth/v1/get-session refuses the key. A ban whose banExpires has passed no longer counts, matching the admin plugin. A deleted owner's keys stop working too.
Rate limit
Better Auth rate limits each key using the auth config's rateLimit.window (seconds) and rateLimit.max — 100 requests per 60 seconds by default. The limit is always on. It counts per key, so rateLimit.enabled: false (which turns off the per-IP limiter) doesn't affect it. Over the limit the API answers 429 AUTH_RATE_LIMITED. The values are stamped on each key when it is created, so a change applies to new keys only.