Two credential kinds

HeaderFormatLifetime
Person (portal session)Authorization: Bearer <jwt>HS256 JWT1 hour, and invalidated immediately on every app-api restart
Runtime (machine)X-API-Key: <key>erk_rt_v1_<26-char lookup>_<43-char secret>Governed by key state — see Key lifecycle below

A portal JWT carries uid, email, role, org and an epoch claim tied to the exact app-api process that issued it. If that process restarts or redeploys, every outstanding token stops working immediately, independent of its stated expiry — there is no cross-process token validity.

A Runtime key's shape is frozen: erk_rt_v1_ followed by a 26-character lowercase base32 lookup segment, an underscore, and a 43-character URL-safe-base64 secret segment. A value that does not match this exact shape is rejected before any database lookup is attempted.

What is public

Exactly three routes require no credential: GET /health, GET /ready, and POST /api/v1/auth/login. Every other route — portal and machine alike — authenticates on every request. There is no configuration flag that disables this.

Runtime key scopes

A Runtime key carries one or more of exactly three scopes, and each machine route checks its own scope independently:

ScopeRequired for
runtime:registerPOST /api/v1/runtime/register
fleet:heartbeatPOST /api/v1/fleet/heartbeat
sync:read-activeGET /api/v1/sync-bundles/active

A key presented against a route it is not scoped for is rejected — it never falls through to a broader default.

Rate limits

Both limits are per client IP and return 429 with no retry-after header:

RouteLimit
POST /api/v1/auth/login10 attempts / 10 minutes
Any X-API-Key machine route60 attempts / minute

Key lifecycle

A Runtime key is created by an administrator in the Enterprise portal for an organization that is entitled to Enterprise Runtime registration, and it moves through a small state machine:

  1. unclaimed — freshly created, not yet used to register a Runtime. Expires automatically after 24 hours if never used.
  2. active — bound to exactly one Runtime, either at initial registration or the moment a replacement key is first used.
  3. pending_replacement — a newly issued replacement for an already-active key. Expires after 1 hour if not used. The moment it *is* used on any authenticated request, it becomes active and its predecessor is revoked in the same transaction — replacement is activate-on-first-use, not activate-on-creation.
  4. revoked — either superseded by a successful replacement, or revoked directly by an administrator. A revoked key can never become active again.

Two constraints worth knowing before you script against this: only one pending replacement may exist for a given Runtime at a time (a second attempt returns 409), and an unclaimed key that was never used to register cannot be replaced at all — it must be revoked and a fresh key created instead.

Error semantics

StatusMeaning
401Missing or malformed credential, or a portal JWT that is expired or was issued by a different app-api process (epoch mismatch)
403A valid credential without the required scope or role — for example, a non-admin JWT on an admin-only route, or a Runtime key missing the scope a route requires
409A conflicting write — a policy or configuration version that already exists (versions are immutable), or a second pending replacement key for the same Runtime
429One of the two rate limits above

Headers at a glance

HeaderUsed by
Authorization: Bearer <jwt>Portal session routes
X-API-Key: <key>Runtime machine routes
Content-Type: application/jsonEvery request with a body