Skip to content

API keys & auth

Stratum authenticates requests using API keys or JWT bearer tokens. Each key carries scopes that control what operations it can perform, and keys can optionally be scoped to a specific tenant subtree.

Pass the key in the X-API-Key header:

curl http://localhost:3001/api/v1/tenants \
-H "X-API-Key: sk_live_abc123..."

Keys use a recognizable prefix format: sk_live_ for production, sk_test_ for development. They are 256-bit random values, stored as HMAC-SHA256 hashes (keyed with STRATUM_API_KEY_HMAC_SECRET).

curl http://localhost:3001/api/v1/tenants \
-H "Authorization: Bearer eyJhbG..."

JWTs are verified using the JWT_SECRET environment variable. Without a scopes claim, tokens default to read-only access.

Set JWT_AUDIENCE (and optionally JWT_ISSUER) to require a matching aud (and iss) claim. When they are unset the claims are not checked, so set JWT_AUDIENCE whenever JWT_SECRET is also used to sign tokens for another application.

Three scopes control access:

Scope Allows
read GET, HEAD, OPTIONS requests
write POST, PUT, PATCH, DELETE on standard routes
admin Admin-only routes (key management, audit, GDPR, regions)

Scopes are ranked: admin includes write, and write includes read. A key with ["read", "write"] can read and mutate standard routes. A key with ["admin"] can also call admin routes. A refused request gets 403 FORBIDDEN with a message that names the scope the route requires.

A new key gets the scopes read and write. createApiKey and POST /api/v1/api-keys take no scopes: to give a key read only, or admin, assign it a role (see Role-Based Access Control). The role’s scopes then replace the key’s own.

The SDK middleware (expressMiddleware, fastifyPlugin, resolveTenant), the NestJS guard and the Hono resolver call GET /api/v1/tenants/:id/context. That route needs only the read scope. Give an app server a key scoped to the tenant it serves, with a role whose scopes are ["read"]. The key resolves that tenant and its descendants. Use a global key only when one server serves tenants in more than one subtree.

These always require the admin scope:

Route Pattern Description
/api/v1/api-keys/* API key management
/api/v1/audit-logs/* Audit log access
/api/v1/maintenance/* Purge and rotation operations
/api/v1/regions/* Region management
/api/v1/tenants/:id/purge GDPR hard-delete
/api/v1/tenants/:id/export GDPR data export
/api/v1/tenants/:id/migrate-region Region migration
const { plaintext_key, id } = await stratum.createApiKey(
"tenant-uuid",
{ name: "my-service" }
);
// Save plaintext_key: it is only returned once

Via the API:

curl -X POST http://localhost:3001/api/v1/api-keys \
-H "X-API-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"tenant_id": "TENANT_UUID", "name": "my-service"}'
  • Tenant-scoped (tenant_id set): Can only access data for that tenant and its descendants
  • Global (tenant_id null): Unrestricted access to all tenants

To create a global key, pass null as the tenant ID:

// A global key has no tenant: tenant_id is null.
const { plaintext_key, tenant_id } = await stratum.createApiKey(null, { name: "admin-service" });

Tenant scope enforcement checks the ancestry path on every request, a key scoped to tenant A cannot access tenant B’s data, even via list endpoints.

Keys can specify custom rate limits at creation:

curl -X POST http://localhost:3001/api/v1/api-keys \
-H "X-API-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "TENANT_UUID",
"name": "limited-key",
"rate_limit_max": 50,
"rate_limit_window": "1 minute"
}'
Field Range Default
rate_limit_max 1 to 100,000 Global RATE_LIMIT_MAX (100)
rate_limit_window e.g., "30 seconds", "1 hour" Global RATE_LIMIT_WINDOW (1 minute)

Rate-limited responses include standard headers:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 50
X-RateLimit-Remaining: 0

Rotate a key to issue a new one while revoking the old:

const newKey = await stratum.rotateApiKey("key-uuid");
// newKey.plaintext_key is the replacement key

Via the API:

curl -X POST http://localhost:3001/api/v1/api-keys/KEY_UUID/rotate \
-H "X-API-Key: YOUR_ADMIN_KEY"
await stratum.revokeApiKey("key-uuid");

Via the API:

curl -X DELETE http://localhost:3001/api/v1/api-keys/KEY_UUID \
-H "X-API-Key: YOUR_ADMIN_KEY"

Find keys that haven’t been used recently:

const dormant = await stratum.listDormantKeys(90); // unused > 90 days

Via the API:

curl "http://localhost:3001/api/v1/api-keys/dormant?days=90" \
-H "X-API-Key: YOUR_ADMIN_KEY"

Beyond flat scopes, Stratum supports named roles that bundle scopes into reusable collections.

const role = await stratum.createRole({
name: "Editor",
scopes: ["read", "write"],
description: "Read and write access",
});

Roles can be global (tenant_id null) or tenant-scoped.

await stratum.assignRoleToKey("key-uuid", role.id);
// Resolve effective scopes (role scopes override key defaults)
const scopes = await stratum.resolveKeyScopes("key-uuid");
// ["read", "write"]

Removing a role reverts the key to its own default scopes:

await stratum.removeRoleFromKey("key-uuid");

Sensitive config values and webhook secrets are encrypted at rest using AES-256-GCM:

  • Cipher: AES-256-GCM with HKDF-SHA256 key derivation
  • IV: 12 bytes, randomly generated per encryption
  • Format: v1:<iv_hex>:<authTag_hex>:<ciphertext_hex>

Set the encryption key:

export STRATUM_ENCRYPTION_KEY=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")

Re-encrypt all sensitive data with a new key:

curl -X POST http://localhost:3001/api/v1/maintenance/rotate-encryption-key \
-H "X-API-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"old_key": "current-key", "new_key": "new-key"}'

The rotation commits in batches, so it is not atomic. If it fails partway, send the same request again: values already on the new key stay unchanged. The response lists values that decrypt with neither key in unreadable. After rotation, update STRATUM_ENCRYPTION_KEY and restart.

To move the data to a new HKDF salt, also send old_salt and new_salt in hex. After that rotation, update STRATUM_HKDF_SALT too.

  1. Use tenant-scoped keys for application services that only need one tenant
  2. Use read scope for app servers that resolve tenants, and for monitoring and analytics
  3. Rotate keys regularly using the rotate endpoint for zero-downtime replacement
  4. Monitor dormant keys and revoke keys unused for 90+ days
  5. Never commit keys to source control
  6. Set JWT_SECRET in production (the dev fallback is insecure). The control plane refuses to start in production with a secret shorter than 32 bytes or a published placeholder value
  7. Set STRATUM_API_KEY_HMAC_SECRET in production for keyed hash storage. Outside development and test, Stratum refuses to start with a secret shorter than 32 bytes