Authentication
Credential shapes, scopes, rate limits, key lifecycle, and error semantics.
Two credential kinds
| Header | Format | Lifetime | |
|---|---|---|---|
| Person (portal session) | Authorization: Bearer <jwt> | HS256 JWT | 1 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:
| Scope | Required for |
|---|---|
runtime:register | POST /api/v1/runtime/register |
fleet:heartbeat | POST /api/v1/fleet/heartbeat |
sync:read-active | GET /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:
| Route | Limit |
|---|---|
POST /api/v1/auth/login | 10 attempts / 10 minutes |
Any X-API-Key machine route | 60 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:
unclaimed— freshly created, not yet used to register a Runtime. Expires automatically after 24 hours if never used.active— bound to exactly one Runtime, either at initial registration or the moment a replacement key is first used.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 becomesactiveand its predecessor is revoked in the same transaction — replacement is activate-on-first-use, not activate-on-creation.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
| Status | Meaning |
|---|---|
401 | Missing or malformed credential, or a portal JWT that is expired or was issued by a different app-api process (epoch mismatch) |
403 | A 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 |
409 | A conflicting write — a policy or configuration version that already exists (versions are immutable), or a second pending replacement key for the same Runtime |
429 | One of the two rate limits above |
Headers at a glance
| Header | Used by |
|---|---|
Authorization: Bearer <jwt> | Portal session routes |
X-API-Key: <key> | Runtime machine routes |
Content-Type: application/json | Every request with a body |