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) | |
|---|---|---|
| Credential | API key | Admin JWT |
| Sent as | x-api-key metadata | Authorization: Bearer … |
| Lifetime | Until revoked | One hour by default |
| Belongs to | A tenant | An admin login, bound to a tenant |
| Used by | Your workers | The 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.
{
"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.
[
{"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
| Situation | gRPC status | Why |
|---|---|---|
| No key, unknown key, or revoked key | UNAUTHENTICATED | All three are answered identically — the response never reveals whether a key exists. |
| Key belongs to a suspended or deleted tenant | UNAUTHENTICATED | The lookup additionally requires an active tenant, so a frozen tenant's traffic stops even though its keys are still unrevoked. |
| The key store is unreachable | UNAVAILABLE | An 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.
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.tenant— the 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-keymetadata header, which does not end up in access logs or referrers. - Treat
UNAUTHENTICATEDas terminal (fix the key) andUNAVAILABLEas retryable (back off).
Next: REST API reference for the endpoints these credentials open.