API keys

Create and manage API keys for server-side access without user sessions.

#API keys

API keys provide a way to authenticate server-side scripts, cron jobs, and automation workflows without managing user sessions. Each key starts with sk- and is stored as a SHA-256 hash in the database.

#When to use API keys

Use case Recommended auth
Frontend app (browser) Session tokens via auth.signIn()
Server-side scripts API keys
Cron jobs / scheduled tasks API keys
CI/CD pipelines API keys
Warning

Never embed API keys in client-side code. They grant full access to the API. Use session tokens for browser apps.

#Create an API key

# Via the CLI
wrangler d1 execute sveltesync --command "INSERT INTO api_keys (id, key_hash, label, capabilities, created_at) VALUES (...)"

# Or via the admin dashboard
# Settings → API Keys → Create new key

#Use an API key

// SDK
const sdk = new EmuView({
	url: 'https://your-api.example.com',
	auth: { apiKey: 'sk-your-api-key' }
});

const records = await sdk.collection('users').list();
# HTTP
curl https://your-api.example.com/api/v1/collections/users/records \
  -H "Authorization: Bearer sk-your-api-key"

#Key properties

Property Description
Prefix All keys start with sk-
Storage Stored as SHA-256 hash — the raw key is only shown once at creation
Capabilities Can be scoped to specific operations (read, write, delete)
Rotation Delete and recreate to rotate; update your environment variables

#Scoping a key (least privilege)

By default a key inherits its role's full access. Set an explicit scopes array to narrow it to specific resource:action pairs, enforced centrally on both the control plane and the data plane — so a scoped key is denied everything it isn't granted, including collections it never names (a leaked key is no longer total). Scopes can only narrow the role, never widen it.

Scope strings support * wildcards and path segments:

You want Scope string
Read one collection collections/<name>:read
Write one collection collections/<name>:create / :update / :delete (or collections/<name>:*)
Read every collection collections/*:read
Sign file download URLs system/files:read
Invoke flows system/automate:execute
List / read flows system/automate:read
Read search-index status system/search-indexes:read
// A key that can only read one collection and sign its file URLs:
{ "scopes": ["collections/avatars:read", "system/files:read"] }

Gotcha: a key created with no scopes (a null scopes column) inherits its role's full access — so a broad role plus null scopes is a broad key. Set scopes explicitly whenever you want least privilege.

#Every key carries a role, and the default is api

Omit roleId when creating a key — which is what the dashboard's "Default — built-in api role" option does, and the only thing available to an operator without permission to read roles — and the key is created against the project's built-in api role. The role is resolved at creation and comes back in the response as roleId and roleName, so what the key carries is recorded rather than inferred later.

That matters because a key whose role does not resolve is refused, not defaulted: if the role is deleted afterwards, the column is set to null and the key answers 401 naming the problem. Fail-closed is the point — a live credential must not silently change meaning when a role disappears — but it means a key that was never given a role at all is equally dead, which is why one is assigned up front.

#Service keys and delegated keys

A key is one of two things, and the choice is made when you create it.

A service key is the kind every key was until now. It carries a Role of its own, belongs to no person, and outlives everybody — the right shape for a backend that has to keep running when someone leaves. Records it creates are owned by the key, so revoking it orphans them.

A delegated key is bound to a person and presents them. It has no Role of its own; it inherits theirs, live, along with their Crews. Records it creates are owned by that person, a Share addressed to them is reachable over it, and taking a Role away from them takes it away from the key in the same breath — there is nowhere for a delegated key to hold authority its person does not have. This is what a blueprint install from a terminal needs, because an install performed by nobody leaves resources owned by nobody.

You may bind a key to yourself only. Minting a credential that acts as somebody else is a different decision from being allowed to issue keys at all, and this endpoint does not grant it.

#A delegated key reaches no Crew until you name one

The two narrowings on a delegated key read in opposite directions, and the second one is the one people get wrong:

Field Left empty means
scopes Everything the person's Roles allow. Empty is not a restriction.
crewScope No Crew at all. Empty is the tightest setting, not the loosest.

That asymmetry is deliberate. scopes predates delegation, and every key written before the field existed has it empty — reading that as "deny" would have killed every key in existence the day it shipped. crewScope has no such history: nothing predates it, so an empty field is always a new credential whose owner did not ask for crew reach, and the safe reading of "did not ask" is "does not get".

So a delegated key with no crewScope still acts as its person for everything they own, and reaches nothing that was shared to their Crews. Naming a Crew the person is not a member of grants nothing either — the reach is the overlap of the two, never the sum.

#Changing your password revokes your keys

A password change deletes the keys you issued and the keys that present you, and bumps the cache epoch so they stop working within seconds rather than at the end of a cache lifetime. Both halves matter: a key that acts as you is at least as compromised by a stolen password as one you merely created.

Keys that predate this bookkeeping carry no issuer and are not revoked by anybody's password change. Re-issue one if you want the guarantee.

#Keys cannot be used with roles that require MFA

If a key's role has multi-factor authentication required, the key is refused with 403 and a message naming the role. This is not a bug to work around: an API key is a single secret with no second factor, so a key carrying an MFA-required role would silently reduce that requirement to nothing. Assign the key a role that does not require MFA, or drop the MFA requirement from the role — deliberately, and knowing what it costs.

#How quickly a revoked key stops working

Deleting or revoking a key removes it from the database immediately, but resolved key identities are cached in memory to keep a high-volume caller off the database on every request. The timing is worth knowing before you rely on it during an incident.

Situation When the key stops working
Normal operation Within ~10 seconds
security.api_key_cache_seconds set to 0 Immediately — caching is disabled and every request re-reads the database
The KV namespace is unavailable Up to security.api_key_cache_seconds (default 1 hour) — see below

Revocation propagates between isolates through a counter ("epoch") stored in KV. Changing a key or a role bumps that counter, which makes every cached identity unreachable at once. The counter itself is re-read about every 10 seconds, which is where the normal ~10s figure comes from.

Warning

If KV is unavailable, revocation degrades rather than failing. The epoch read falls back to a default value, so the counter cannot change and already-cached identities stay valid until their own cache entry expires — up to security.api_key_cache_seconds, one hour by default.

If you are revoking a key because it leaked, do not assume the ~10 second figure holds during a Cloudflare incident. Either set security.api_key_cache_seconds to 0 for the duration, or rotate BETTER_AUTH_SECRET / redeploy, which discards every in-memory cache by replacing the isolates.

Setting security.api_key_cache_seconds to 0 removes this window entirely at the cost of one database read per API-key request. That is the right setting for an instance where immediate revocation matters more than request latency.

#What you learned

  • API keys use sk- prefix and are stored as SHA-256 hashes
  • Pass via auth: { apiKey: 'sk-...' } in the SDK or Authorization: Bearer header
  • Never expose API keys in client-side code

#Next steps