Authentication

API keys for the gRPC data plane, admin JWTs for the REST control plane — issuing, storage, rotation, and revocation.

Two credentials, one per plane

Data plane (gRPC)Control plane (REST)
CredentialAPI keyAdmin JWT
Sent asx-api-key metadataAuthorization: Bearer …
LifetimeUntil revokedOne hour by default
Belongs toA tenantAn admin login, bound to a tenant
Used byYour workersThe dashboard and your tooling

They are not interchangeable. The gRPC server runs its own interceptor on its own port and never looks at Authorization; the servlet security chain never looks at x-api-key. Sending the wrong one is indistinguishable from sending nothing.

API keys

Issuing

Keys are minted per tenant, either from the dashboard's API keys screen or with POST /api/tenants/{tenantId}/api-keys. An optional label lets you tell them apart later ("worker-01", "staging") — it is not part of the credential.

json201 Created — the only response that contains rawToken
{
  "id": "5f1c2b40-…",
  "rawToken": "rp_9Q3xK7bT…",
  "label": "worker-01",
  "prefix": "rp_9Q3xK7bT",
  "createdAt": "2026-07-29T09:12:44Z"
}

Format and storage

A raw key is rp_ followed by the base64url encoding of 256 random bits from a cryptographic RNG. The rp_ prefix namespaces it and makes an accidental leak greppable in logs and repositories.

What is persisted is deliberately not enough to authenticate with:

  • the SHA-256 digest of the raw key — the lookup hashes what you send and compares digests, so the raw key is never stored or compared in the clear;
  • a display prefix (rp_ + 8 characters) — non-secret, and the only thing listings ever show;
  • the label, creation time, and revocation time.

The raw key is shown exactly once

Because only a digest is kept, there is no operation anywhere that can return a key's value again — not the API, not the dashboard, not the database. If you lose it, you issue a new one. Listing keys is therefore safe by construction: it cannot leak key material even to a legitimate admin.

jsonGET …/api-keys — no key material, ever
[
  {"id":"5f1c2b40-…","label":"worker-01","prefix":"rp_9Q3xK7bT",
   "createdAt":"2026-07-29T09:12:44Z","revokedAt":null},
  {"id":"a08e77c1-…","label":"laptop","prefix":"rp_LmN4pQr8",
   "createdAt":"2026-06-02T11:40:10Z","revokedAt":"2026-07-01T08:00:00Z"}
]

Rotation

There is no "rotate" operation, and that is intentional — rotation is issue-then-revoke, which is also the only sequence with no window where nothing works:

  • Issue a new key. Both keys are now valid.
  • Roll the new key out to your workers and confirm traffic is flowing on it.
  • Revoke the old key by its id.

Give each deployment unit its own labelled key. Then rotating one worker, or revoking a key that leaked from one machine, does not interrupt the others.

Revocation is immediate

DELETE /api/tenants/{tenantId}/api-keys/{keyId} stamps the key as revoked. The gRPC lookup only ever considers keys with no revocation timestamp, so the key stops working on its very next call — there is no cache to wait out. Revoking an already-revoked or unknown key answers 404, without revealing which of the two it was.

What a rejected call looks like

SituationgRPC statusWhy
No key, unknown key, or revoked keyUNAUTHENTICATEDAll three are answered identically — the response never reveals whether a key exists.
Key belongs to a suspended or deleted tenantUNAUTHENTICATEDThe lookup additionally requires an active tenant, so a frozen tenant's traffic stops even though its keys are still unrevoked.
The key store is unreachableUNAVAILABLEAn outage must not masquerade as bad credentials: UNAVAILABLE is diagnosable and retryable, a false UNAUTHENTICATED would send you hunting for a key problem that does not exist.

The dashboard session (admin JWT)

The control plane is token-only and stateless: no server session, no login cookie, and therefore no CSRF token — there is nothing ambient for a cross-site request to ride on. You exchange credentials for a JWT once and attach it to each request.

bashPOST /api/auth/login — the one public control-plane endpoint
curl -sS -X POST "https://$RP_HOST/api/auth/login" \
  -H 'Content-Type: application/json' -d '{"username":"admin","password":"…"}'

# {"token":"eyJhbGciOiJIUzI1NiJ9…","tokenType":"Bearer","expiresInSeconds":3600}

The token is HS256-signed by the service and carries two claims that matter:

  • sub — the admin username.
  • tenantthe tenant boundary every scoped read is evaluated against. Server-decided at login; never taken from a query parameter, a header, or a request body.

That last point is the tenancy rule in one line: pool reads, the events feed, and usage are all scoped to the token's own tenant claim, so there is no request shape that can point them at another tenant. A token with no tenant claim is rejected rather than defaulted.

Cross-tenant requests fail closed, and quietly

Key management takes a tenantId in the path. If it is not the tenant your token is bound to, the answer is 403 forbidden — and that check runs before the "does this tenant exist" check, so a 403-versus-404 difference cannot be used to probe which tenants exist.

Login is rate-limited per source IP

v1 has a single admin account, so locking "the account" after failed attempts would be a self-inflicted outage. The limiter blocks the source IP instead: five failed attempts in fifteen minutes block that IP for fifteen minutes, answered with 429 and a Retry-After header. A successful login clears that IP's counter, and a global per-second cap sits behind it as a backstop against attempts spread across many addresses.

Expiry

Tokens last one hour by default and there is no refresh endpoint: when the token expires, every call answers 401 and you log in again. Scripts that run longer than an hour should re-login on 401 rather than assume a token is good for the whole run. The dashboard does exactly this, sending you back to the login screen.

Operational checklist

  • One labelled API key per deployment unit — not one shared key for the fleet.
  • Keys go straight into your secret store from the issue response; nothing else can read them back.
  • Rotate by issuing first and revoking second, so there is no gap.
  • Never put a key in a URL. It belongs in the x-api-key metadata header, which does not end up in access logs or referrers.
  • Treat UNAUTHENTICATED as terminal (fix the key) and UNAVAILABLE as retryable (back off).

Next: REST API reference for the endpoints these credentials open.