API keys for your customers
apikeysIssue each customer API keys with scopes, rotation without downtime, instant revoke and a rate limit, and verify them on every call.
apikeysFree in your app, Apache-2.0Status 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.
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/minimport { apikeys } from "mainsdk";
// a signed-in admin issues a key; the secret is shown once
const { key, secret } = await apikeys.create({ name: "CI", scopes: ["orders.read"] });
// in the customer-facing API
const { tenant, scopes } = await apikeys.verify(req);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");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.
apikeys:
driver: dbChange this one line to swap providers; your calls stay the same.Your database integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-APIKEY-0010 | Checkmainsdk check, before deploy | A key in this app's format, with a valid checksum, is committed to source |
MAIN-APIKEY-0011 | Checkmainsdk check, before deploy | A scope under apikeys.scopes that no access permission defines |
MAIN-APIKEY-0001 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | Unknown, revoked or expired key (401). The body is the same for all three; the log says which |
MAIN-APIKEY-0003 | RuntimeError responses and logs | The key's scopes don't include the permission this action needs (403) |
MAIN-APIKEY-0004 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | The key's rate limit is reached (429, with Retry-After) |
MAIN-APIKEY-0007 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | rotate on a revoked or expired key (409) |
MAIN-APIKEY-0012 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| apikeys@1 contract and key format | Phase 1 | 0.3 | apikeys#contract |
| Keys with prefix | Phase 1 | 0.3 | apikeys#format |
| Verify with a digest lookup and the tenant set in context | Phase 1 | 0.3 | apikeys#verify |
| Scopes that are access permissions | Phase 1 | 0.3 | apikeys#scopes |
| List | Phase 1 | 0.3 | apikeys#lifecycle |
| Last use written in batches | Phase 1 | 0.3 | apikeys#last-used |
| Per-key rate limits and audit records | Phase 1 | 0.3 | apikeys#rate-audit |
| Key format redacted in logs | Phase 1 | 0.3 | apikeys#redaction |
| Check for committed keys and undefined scopes | Phase 1 | 0.3 | apikeys#check |
| Recently verified keys served when the store is down | Phase 1 | 0.3 | apikeys#fail-static |
| db and memory drivers with a conformance suite | Phase 1 | 0.3 | apikeys#drivers |
| apikeys helpers in the testing kit | Phase 1 | 0.3 | apikeys#testing |
Quick start
apikeys:
prefix: acme
scopes: [orders.read, orders.write]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
}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
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.
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
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 everywhereThe 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.
Swapping drivers
apikeys:
driver: local # was db; tests and local mode onlymainsdk 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.
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: how a verified key sets the tenant.
- Permissions: the permissions behind scopes.
- Rate limits and quotas: the per-key limit.
Was this page helpful?