Programmatic authentication
A program reaches the public API with one of two credential families. Both are sovereign: a node signs and verifies them against its own locally held keys, so they work on an air-gapped rack with no call to any external identity provider.
- API keys: a long-lived
s9k_live_…secret a caller presents as a bearer token. Best for a service that calls the API directly. - OAuth 2.1 access tokens: short-lived, self-signed EdDSA JWTs a caller obtains from the node's authorization server and presents as a bearer token. Best for delegated access, third-party clients, and human-in-the-loop consent.
Whichever you present, effective authority is the intersection of the acting entity's roles and the credential's own scopes, a credential can only ever narrow what its owner could already do, never widen it.
Every surface named here is served on the node's public API today. One practical note for testing: a healthy auth response proves the credential verified, which is a different statement from any particular downstream data path being complete. Test the two separately.
API keys (s9k_live_)
An API key is an opaque secret with the prefix s9k_live_. The node stores only
its SHA-256 hash: the secret itself is shown once, at mint, and never again.
A key is owned by an entity (a user, service account, or other principal) and
carries its own constraints:
| Field | Meaning |
|---|---|
scopes | The permission selectors this key may exercise. Empty or * means "the owner's full authority"; a dotted selector (e.g. catalog.read) narrows to a subset. Effective authority = owner's roles ∩ these scopes. |
rate_limit_rpm | Requests-per-minute ceiling enforced per key. |
allowed_ips | If set, the caller's IP must match (exact or CIDR); otherwise the request is refused. |
allowed_regions | If set, the request must be served from a listed region; otherwise refused. |
Lifecycle
Keys are minted, rotated, and revoked through the API-keys surface. Rotation issues a new secret while the previous one keeps working for a bounded grace window (so a caller can roll over without downtime); revocation ends both at once.
Figure: an API key's lifecycle. Rotation overlaps a new secret with the outgoing one for a bounded grace window; revocation refuses both immediately.
Table equivalent
| State | How reached | Which secret verifies |
|---|---|---|
| Active | Mint (secret shown once) | The one secret |
| New secret active | Rotate | New secret; the previous one until the grace window ends |
| Only new valid | Grace window elapses | New secret only |
| Revoked | Revoke (from any state) | Neither: all presentations refused |
Admins may mint or rotate a key on behalf of another entity they are authorized for; the acting admin is recorded on the audit trail, and the key is still owned by the target entity (its authority is that entity's roles ∩ the key's scopes).
Disposing of the entity disposes of its keys
An API key is a standing authentication surface, so it has to die when its owner does. The entity-lifecycle cascade tears down keys at the same depth as the entity itself:
| Entity action | What happens to its keys |
|---|---|
| Archive or soft-delete | Keys are soft-deleted, recoverable within the retention window. The key hash is retained until purge |
| Purge | Keys are hard-deleted and tombstoned, and the sweep also collects keys that were already soft-deleted |
This closed a real gap: the cascade previously tore down an entity's certificates, sealed keys and multi-factor enrolment but left its API keys behind, so a purged entity's keys kept authenticating. Depth-matching is the fix, and it is the reason "the entity is gone" and "its credentials are gone" are now the same statement.
Keys resolve at the edge, offline
An API key authenticates as a directory entity but may carry less authority than that entity. The effective authority of a request is:
effective = RBAC(entity) ∩ selected(scopes)
plus the key's IP and region constraints.
An Edge Agent recomputes exactly that offline, so an air-gapped caller can be authenticated with no call home. The device holds no key store, so the record it resolves against is supplied to it: a cell mints a signed API-key records bundle projecting its active keys, and that bundle is hand-carried to the dark device.
Without the bundle the device cannot resolve a presented key at all, which is the honest shape of the dependency: offline authentication is real, and it requires the record to have been carried there first.
OAuth 2.1
The node runs its own OAuth 2.1 authorization server. Access tokens are
self-signed EdDSA JWTs with iss=system9://<cell>; a resource server (a cell
or the edge) verifies them offline against the node's published key set
(GET /oauth/jwks.json), no network round-trip, no external IdP. The server
publishes standard discovery metadata at
/.well-known/oauth-authorization-server (RFC 8414).
Grants
| Grant | For | Notes |
|---|---|---|
client_credentials | A service authenticating as itself | The client presents its credentials (an API key can serve as the client secret) and receives an access token. |
authorization_code + PKCE | A client acting for a user, with consent | Code-for-token exchange; PKCE (S256) is required. The user approves the requested scopes on a consent screen. |
urn:ietf:params:oauth:grant-type:device_code | An input-constrained device | The device shows a short user code; the user approves it on another screen. The approving identity is taken from the authenticated session, never from the request. |
Figure: the OAuth grant flow. The client obtains a short-lived, self-signed access token from the node's authorization server and presents it; the resource server verifies it offline against the published keys and the token's scopes.
Table equivalent
| Step | Actor | Check |
|---|---|---|
| Token request | Client → AS | Grant-specific: client auth, or code + PKCE S256, or device approval |
| Token issued | AS → Client | EdDSA JWT, iss=system9://<cell>, short expiry, requested scopes ∩ authority |
| Resource call | Client → RS | Bearer token presented |
| Verify | RS | Offline signature vs JWKS, iss/alg/exp/aud, scope-satisfies-request: deny-closed on any failure |
Introspection and revocation
- Introspection (RFC 7662):
POST /oauth/introspect(client-authenticated) returns{"active": true, …claims}for a token that verifies and is not revoked, or{"active": false}and nothing else for anything else (an expired, revoked, malformed, or foreign token are indistinguishable to the caller, no oracle). - Revocation (RFC 7009):
POST /oauth/revoke(client-authenticated) places the token on a denylist. It is idempotent and always returns 200 (revoking an unknown or already-revoked token is a silent no-op). A revoked token is then refused by both introspection and the bearer path.
DPoP: sender-constrained tokens
For the strongest tier, a token can be bound to a key the client holds (DPoP,
RFC 9449), so a stolen bearer token is useless without the client's private key.
The client sends a signed DPoP proof on each request; the access token
carries cnf.jkt (the thumbprint of that key) and the resource server checks
that every request's proof matches.
Figure: DPoP binding. The access token records the client key's thumbprint at issuance; every request must carry a fresh, non-replayed proof from that same key bound to the request's method and URL, or it is refused.
Table equivalent
| Phase | What is checked |
|---|---|
| Issuance | The token records cnf.jkt = the SHA-256 thumbprint of the client's public key |
| Each request | Proof typ=dpop+jwt, asymmetric alg, embedded public key; signature valid; htm/htu match the request; iat fresh; jti unseen (replay refused); ath binds the token; proof key thumbprint == the token's cnf.jkt |
| Any mismatch | Refused, deny-closed |
The authorization server advertises the proof-signing algorithms it accepts
(EdDSA, ES256, ES384, RS256) in its discovery metadata.
A request that terminates at the Edge Agent is held to the same sender
constraint. The edge verifies the bearer token's signature and scopes offline,
and when the token carries cnf.jkt it is honoured only if that request also
carries a matching DPoP proof: proof-of-possession of the bound key, bound to the
method, the request URI and the exact token, and replay-guarded by jti. A bound
token with a missing or invalid proof is refused. A plain bearer token (no cnf)
needs no proof, as on the cell.
Certificate binding (RFC 8705, cnf."x5t#S256") is enforced alongside it where
the edge terminates client mTLS: the bound thumbprint must match the leaf this
request actually presented, and a cert-bound token arriving with no leaf is
refused. A token may carry both bindings, and both must hold.
The caveat worth knowing before you deploy one: the edge derives the URI the
proof must bind to from the request's own host and route. A TLS-terminating or
path-rewriting proxy in front of it makes the client sign a different htu or
htm than the edge reconstructs, and the edge does not read
X-Forwarded-Proto or X-Forwarded-Host. Terminate TLS at the edge, or match
the proxy's external scheme, host and path to what the edge sees.
Scoped access tokens (SATs)
A scoped access token is a short-lived, narrowly-scoped token minted for a specific delegated task. Like API keys, a SAT's authority is the intersection of its owner's roles and the SAT's declared scope, and it is presented as a bearer token. Prefer a SAT over a long-lived API key when the access is bounded in time or purpose.
Related topics
- Public API reference, the served OpenAPI document and how each operation's status is read.
- Authentication, mutual TLS, human authentication, and federation with an external identity provider.
- Authorization and delegation : how roles, scopes, and delegated grants combine into an effective decision.