---
title: "API keys for your customers"
description: "Issue each customer API keys with scopes, rotation without downtime, instant revoke and a rate limit, and verify them on every call."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "apikeys"
canonical: "https://mainsdk.dev/docs/interfaces/apikeys"
updated: "2026-10-28"
---

# API keys for your customers

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

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/apikeys · 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

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/apikeys.md
- Category: Identity and access

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?)`

In main.yaml:

```yaml
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
```

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

Pick one in main.yaml with `apikeys.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Your database (the tenant's region store) | `db` | yes |  |
| Local (tests and local mode) | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-APIKEY-0010` | check | A key in this app's format, with a valid checksum, is committed to source |
| `MAIN-APIKEY-0011` | check | A scope under `apikeys.scopes` that no `access` permission defines |
| `MAIN-APIKEY-0001` | runtime | No key, or a malformed one: wrong prefix, wrong length, bad checksum, or a key sent in the query string (401). Decided offline |
| `MAIN-APIKEY-0002` | runtime | Unknown, revoked or expired key (401). The body is the same for all three; the log says which |
| `MAIN-APIKEY-0003` | runtime | The key's scopes don't include the permission this action needs (403) |
| `MAIN-APIKEY-0004` | runtime | Invalid `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-0005` | runtime | The 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-0006` | runtime | The key's rate limit is reached (429, with `Retry-After`) |
| `MAIN-APIKEY-0007` | runtime | The key's environment doesn't match: a `test` key in `env: prod`, or a `live` key anywhere else (401). Decided offline |
| `MAIN-APIKEY-0008` | runtime | The 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-0009` | runtime | `rotate` on a revoked or expired key (409) |
| `MAIN-APIKEY-0012` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| apikeys@1 contract and key format | 1 | 0.3 | `apikeys#contract` |
| Keys with prefix | 1 | 0.3 | `apikeys#format` |
| Verify with a digest lookup and the tenant set in context | 1 | 0.3 | `apikeys#verify` |
| Scopes that are access permissions | 1 | 0.3 | `apikeys#scopes` |
| List | 1 | 0.3 | `apikeys#lifecycle` |
| Last use written in batches | 1 | 0.3 | `apikeys#last-used` |
| Per-key rate limits and audit records | 1 | 0.3 | `apikeys#rate-audit` |
| Key format redacted in logs | 1 | 0.3 | `apikeys#redaction` |
| Check for committed keys and undefined scopes | 1 | 0.3 | `apikeys#check` |
| Recently verified keys served when the store is down | 1 | 0.3 | `apikeys#fail-static` |
| db and memory drivers with a conformance suite | 1 | 0.3 | `apikeys#drivers` |
| apikeys helpers in the testing kit | 1 | 0.3 | `apikeys#testing` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
apikeys:
  prefix: acme
  scopes: [orders.read, orders.write]
```

```ts title="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
}
```

```ts title="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

```yaml title="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
```

| Key | What it does |
| --- | --- |
| `driver` | Where key records live. `db` is a table owned by the tenant, in its region. `local` keeps them in the process |
| `prefix` | The visible start of every key, and the format `mainsdk check` looks for in source |
| `header` | Header read after `Authorization: Bearer`. A key in the query string is always refused |
| `scopes` | The `access` permissions a key may carry |
| `role` | The tenant role the policy treats a key as. Default `member`, so a key never widens what that role allows |
| `max-ttl` | The longest expiry allowed, and the default. Keys that never expire are not offered |
| `limits.default` | Rate limit for each key |

## What you get

| Call | What 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_DDL` | The 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.

```ts title="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

```ts title="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

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-APIKEY-0001` | No key, or a malformed one (401) | Send `Authorization: Bearer <key>` |
| `MAIN-APIKEY-0002` | Unknown, revoked or expired key (401) | Create or rotate a key |
| `MAIN-APIKEY-0003` | The key lacks the scope (403) | Create a key with that scope |
| `MAIN-APIKEY-0004` | `create` 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-0005` | The 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-0006` | The key's rate limit is reached (429) | Wait for `Retry-After` |
| `MAIN-APIKEY-0007` | A `test` key in production or a `live` key elsewhere (401) | Use a live key in production and a test key everywhere else |
| `MAIN-APIKEY-0008` | The key store cannot be reached and the key was not verified in the last 15 minutes (503, retryable) | Retry; check the store |
| `MAIN-APIKEY-0009` | Rotating a revoked or expired key (409) | Create a new key |
| `MAIN-APIKEY-0010` | A key in your format is committed to source | Remove it and revoke the key |
| `MAIN-APIKEY-0011` | A scope no `access` permission defines | Define the permission |
| `MAIN-APIKEY-0012` | No such key in this tenant (404) | Use an id from `apikeys.list()` |

The full list is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

```yaml title="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](https://mainsdk.dev/docs/interfaces/testing).

```ts title="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

- [Tenants](https://mainsdk.dev/docs/interfaces/tenant): how a verified key sets the tenant.
- [Permissions](https://mainsdk.dev/docs/interfaces/access): the permissions behind scopes.
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits): the per-key limit.
