MainSDK
Search⌘K
Interfaces

Identity and access

API keys for your customers

apikeys

Issue each customer API keys with scopes, rotation without downtime, instant revoke and a rate limit, and verify them on every call.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec apikeysFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

apikeys lets your customers call your app from their own code: scoped, rotating, revocable keys per tenant, rate limited and audited. The secret is shown once and only its digest is stored.

It belongs to one tenant, carries scopes that are your own access permissions, rotates without downtime and revokes within seconds. Do not build your own: key hashing, constant-time compare and redaction are easy to get wrong. It runs inside your app and is free.

The module is built and tested against the local and SQLite stores (spec apikeys). Cloud-side checks and hosted key drivers are planned.

At a glance

apikeys:
  driver: db
  prefix: acme
  header: x-api-key
  scopes: [orders.read, orders.write, reports.export]
  max-ttl: 365d
  role: member                         # the role a key's principal carries
  limits:
    default: 600/min

Calls

  • apikeys.create({ tenant?, name, scopes, expiresAt?, rate? })
  • apikeys.verify(req)
  • apikeys.list({ limit?, cursor? })
  • apikeys.rotate(keyId, { overlap?, expiresAt? })
  • apikeys.revoke(keyId, { reason })
  • API_KEY_DDL(const)
  • new ApiKeys(o)
  • dbApiKeyStore(o)
  • isValidKey(text, prefix)
  • keyRedactor(prefix)
  • localApiKeyStore()
  • parseApiKeys(raw, project?)

Guarantees

Whatever the driver.

  • The secret is shown once; only its SHA-256 digest is stored
  • A bad key fails offline, from its prefix and checksum, with no store lookup
  • A key never widens what the tenant's policy allows
  • Revoke takes effect within 5 seconds on every instance
  • Keys are redacted in logs and caught in source by mainsdk check

Drivers

2 drivers. Select one to see its line in main.yaml.

main.yamlYour database (the tenant's region store) (default)
apikeys:
  driver: db

Change this one line to swap providers; your calls stay the same.Your database integration

Codes

CodeWhere it appearsMeaning
MAIN-APIKEY-0010Checkmainsdk check, before deployA key in this app's format, with a valid checksum, is committed to source
MAIN-APIKEY-0011Checkmainsdk check, before deployA scope under apikeys.scopes that no access permission defines
MAIN-APIKEY-0001RuntimeError responses and logsNo key, or a malformed one: wrong prefix, wrong length, bad checksum, or a key sent in the query string (401). Decided offline
MAIN-APIKEY-0002RuntimeError responses and logsUnknown, revoked or expired key (401). The body is the same for all three; the log says which
MAIN-APIKEY-0003RuntimeError responses and logsThe key's scopes don't include the permission this action needs (403)
MAIN-APIKEY-0004RuntimeError responses and logsInvalid create or rotate input: a scope not under apikeys.scopes, an expiry in the past or past max-ttl, an overlap over 30 days, or an empty name (400)
MAIN-APIKEY-0005RuntimeError responses and logsThe caller may not issue this key: no apikeys.manage permission, a scope the caller doesn't hold, or a tenant other than the current one (403)
MAIN-APIKEY-0006RuntimeError responses and logsThe key's rate limit is reached (429, with Retry-After)
MAIN-APIKEY-0007RuntimeError responses and logsThe key's environment doesn't match: a test key in env: prod, or a live key anywhere else (401). Decided offline
MAIN-APIKEY-0008RuntimeError responses and logsThe key store can't be reached. Keys verified in the last 15 minutes are served from memory and this is logged once; other keys get 503 (retryable)
MAIN-APIKEY-0009RuntimeError responses and logsrotate on a revoked or expired key (409)
MAIN-APIKEY-0012RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
apikeys@1 contract and key formatPhase 10.3apikeys#contract
Keys with prefixPhase 10.3apikeys#format
Verify with a digest lookup and the tenant set in contextPhase 10.3apikeys#verify
Scopes that are access permissionsPhase 10.3apikeys#scopes
ListPhase 10.3apikeys#lifecycle
Last use written in batchesPhase 10.3apikeys#last-used
Per-key rate limits and audit recordsPhase 10.3apikeys#rate-audit
Key format redacted in logsPhase 10.3apikeys#redaction
Check for committed keys and undefined scopesPhase 10.3apikeys#check
Recently verified keys served when the store is downPhase 10.3apikeys#fail-static
db and memory drivers with a conformance suitePhase 10.3apikeys#drivers
apikeys helpers in the testing kitPhase 10.3apikeys#testing

Quick start

main.yaml
apikeys:
  prefix: acme
  scopes: [orders.read, orders.write]
app/api/keys/route.ts
import { apikeys } from "mainsdk";

// runs in the signed-in tenant; the caller needs the apikeys.manage permission
export async function POST() {
  const { key, secret } = await apikeys.create({ name: "CI", scopes: ["orders.read"] });
  return Response.json({ id: key.id, secret });   // the only time the secret is shown
}
app/api/v1/orders/route.ts
import { apikeys } from "mainsdk";

export async function GET(req: Request) {
  const { tenant, scopes } = await apikeys.verify(req);   // throws MAIN-APIKEY-0001 or -0002 (401) for a bad key
  return Response.json({ tenant: tenant.id, scopes });
}

A key looks like acme_live_eu_<43 characters><6-character checksum>: the prefix, the environment, the tenant's region, 32 random bytes and a CRC32 checksum. A typo or a key from another app fails offline, with no lookup.

Configuration

main.yaml
apikeys:
  driver: db                    # db (default) | local (tests and local mode)
  prefix: acme                  # 2 to 12 lowercase letters and digits; default: the app name
  header: x-api-key             # read when there is no Authorization: Bearer header
  scopes: [orders.read, orders.write, reports.export]
  max-ttl: 365d                 # the longest expiry allowed, and the default
  limits: { default: 600/min }  # per key; create({ rate }) may lower it
KeyWhat it does
driverWhere key records live. db is a table owned by the tenant, in its region. local keeps them in the process
prefixThe visible start of every key, and the format mainsdk check looks for in source
headerHeader read after Authorization: Bearer. A key in the query string is always refused
scopesThe access permissions a key may carry
roleThe tenant role the policy treats a key as. Default member, so a key never widens what that role allows
max-ttlThe longest expiry allowed, and the default. Keys that never expire are not offered
limits.defaultRate limit for each key

What you get

CallWhat it does
new ApiKeys(opts)The class behind apikeys.*. Tests and custom wiring build one; apps use apikeys
parseApiKeys(raw, project?)Reads and validates the apikeys section of main.yaml. project names the default prefix
isValidKey(text, prefix)True when a string has this app's key format and a correct checksum. mainsdk check uses it
keyRedactor(prefix)Returns a function that masks every key of this app's format in a string, keeping prefix, environment and region
localApiKeyStore()The local driver: records in this process, one map per region, for tests and local mode
dbApiKeyStore(opts)The db driver: key records in each region's SQL store. migrate: true creates the tables on Postgres, and sql() returns the statements
API_KEY_DDLThe statements that create the key table and its indexes, for a migration file

API_KEY_DDL (an array of SQL strings) is a constant, not a function.

Examples

Read and verify a key

apikeys.verify(req) reads Authorization: Bearer first, then the header you set. It returns the tenant, the scopes and the key id. Put apikey under tenancy.resolve and the request runs in the key's tenant without a call from you.

app/api/v1/me/route.ts
import { apikeys } from "mainsdk";

export async function GET(req: Request) {
  const { tenant, scopes, keyId } = await apikeys.verify(req);
  return Response.json({ tenant: tenant.id, scopes, keyId });
}

A key never widens what the policy allows. An action whose permission is not among the key's scopes fails with MAIN-APIKEY-0003. A test key in production, or a live key anywhere else, fails with MAIN-APIKEY-0007 before any lookup.

List, rotate and revoke

app/api/keys/manage.ts
import { apikeys } from "mainsdk";

const page = await apikeys.list({ limit: 25 });          // { items, next? }; no secret, no digest

// the old key keeps working for 24 hours, then expires; the new key comes back with its own secret
const keyId = page.items[0].id;
const { key: next, secret } = await apikeys.rotate(keyId, { overlap: "24h" });

await apikeys.revoke(keyId, { reason: "leaked in a public repo" });   // within 5 seconds everywhere

The overlap is 24 hours unless you set it, and 30 days at most. Revoke is safe to repeat. Rotating a revoked or expired key fails with MAIN-APIKEY-0009. A key id from another tenant fails with MAIN-APIKEY-0012.

Errors and checks

CodeWhenFix
MAIN-APIKEY-0001No key, or a malformed one (401)Send Authorization: Bearer <key>
MAIN-APIKEY-0002Unknown, revoked or expired key (401)Create or rotate a key
MAIN-APIKEY-0003The key lacks the scope (403)Create a key with that scope
MAIN-APIKEY-0004create or rotate input is refused: a scope not under apikeys.scopes, an expiry in the past or past max-ttl, an overlap over 30 days (400)Use listed scopes and an expiry within max-ttl
MAIN-APIKEY-0005The caller lacks apikeys.manage, asks for a scope they do not hold, or names another tenant (403)Act as a tenant admin and grant only scopes you hold
MAIN-APIKEY-0006The key's rate limit is reached (429)Wait for Retry-After
MAIN-APIKEY-0007A test key in production or a live key elsewhere (401)Use a live key in production and a test key everywhere else
MAIN-APIKEY-0008The key store cannot be reached and the key was not verified in the last 15 minutes (503, retryable)Retry; check the store
MAIN-APIKEY-0009Rotating a revoked or expired key (409)Create a new key
MAIN-APIKEY-0010A key in your format is committed to sourceRemove it and revoke the key
MAIN-APIKEY-0011A scope no access permission definesDefine the permission
MAIN-APIKEY-0012No such key in this tenant (404)Use an id from apikeys.list()

The full list is in check codes.

Swapping drivers

main.yaml
apikeys:
  driver: local   # was db; tests and local mode only

mainsdk check --env prod refuses local with MAIN-CHECK-0026. The spec names this driver memory; the code reads local, the name every other interface uses, and check refuses both.

Testing

When main.yaml has an apikeys section, createTestApp adds app.apikeys.issue({ tenant, scopes }), which skips the permission checks and returns the secret, withApiKey(req, key) to attach it to a request, and app.apikeys.toHaveAudited(action) to assert an audit record such as apikey.created. See the testing kit.

app/api/v1/me/route.test.ts
import { createTestApp, withApiKey } from "@mainsdk/testing";
import { apikeys } from "mainsdk";

const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\ntenancy:\n  resolve: [apikey]\napikeys:\n  prefix: acme\n  scopes: [orders.read]\n" });
const tenant = await app.tenancy.create({ name: "Acme", region: "eu" });
const key = await app.apikeys.issue({ tenant: tenant.id, scopes: ["orders.read"] });

const { scopes } = await apikeys.verify(withApiKey(new Request("http://x/"), key));   // ["orders.read"]
app.apikeys.toHaveAudited("apikey.created");

Security and privacy

  • Only the SHA-256 digest is stored. The key has 256 bits of entropy, so a fast hash is as safe and keeps verify fast.
  • Digests are compared in constant time. Unknown, revoked and expired keys get the same response.
  • The log redactor replaces your key format with acme_live_eu_[redacted:apikey] in logs, errors and traces.
  • Last use is kept in memory and written once a minute, never in the request path. A verified key is cached for 5 seconds, so a revoke reaches every instance in that time.
  • When the store is down, keys verified in the last 15 minutes are served from memory. Others get a retryable 503.

FAQ

Can a key never expire? No. Raise max-ttl if you need a longer life, and rotate with an overlap.

Are these the keys for MainSDK Cloud? No. These are keys your app issues to its customers.

Can a key carry orders.*? Not yet. List each scope.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet