Authentication
How the data plane, the Management API, and the dashboard check who is calling: credential headers, key format and hashing, identity tokens, and failure codes.
6 min readUpdated for v2.4.0Edit on GitHub
To create tenants and keys, see Tenants and keys. To set up identity tokens, see Identity tokens. To add dashboard sign-in through your IdP, see Dashboard SSO.
Data plane
Every request on port 8080 except GET /health carries a credential, an API
key or an identity token, in one of two headers:
| Header | Form | Used |
|---|---|---|
Authorization | Bearer <credential> | First; the Bearer prefix is case-sensitive |
x-api-key | <credential> | When there is no Authorization: Bearer header |
Anthropic clients send x-api-key by default; set ANTHROPIC_API_KEY to a
gateway key. The gateway strips both headers before forwarding to a model
backend. The MCP gateway (/mcp/{server}) and /v1/verdicts accept the same
credentials.
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $OBLETH_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "example-model", "messages": [{"role": "user", "content": "Hello"}]}'
When OBLETH_JWT_ISSUERS is set, a credential that starts with eyJ, has
exactly two dots and is at most 8 KiB is verified as an identity token.
Anything else is looked up as an API key.
Failure responses
| Condition | Status | message |
|---|---|---|
| No credential | 401 | missing bearer token |
| Unknown or deleted API key | 401 | invalid api key |
Identity token failed verification, or unknown subject with jit_provision: false | 401 | invalid token |
| Redis error during the key or identity lookup | 503 | authentication backend unavailable |
| Verified token, but its identity key could not be created | 503 | identity provisioning unavailable |
| Key disabled | 403 | api key disabled |
| Tenant not active | 403 | tenant is not active |
| Tenant start time not reached | 403 | tenant is not active yet |
| Tenant expired | 403 | tenant access has expired |
| Outside the tenant's weekly schedule | 403 | tenant is outside its scheduled access window |
Errors use {"error": {"message": "…", "type": "obleth_gateway_error"}}.
/v1/messages and /v1/messages/count_tokens return the same statuses in the
Anthropic error envelope, with missing api key when no credential is sent.
A Redis error during the lookup returns 503, not 401, so a Redis outage
does not look like a revoked key to clients.
API keys
| Property | Value |
|---|---|
| Format | sk_ followed by 48 hex characters (24 random bytes), 51 characters in all |
Display prefix (key_prefix) | First 18 characters |
Stored hash (key_hash) | SHA-256 hex of the full key; with a pepper, SHA-256 of key, a zero byte, then the pepper |
| Secret shown | Once, in the create response |
| Redis entry | obleth:key:<hash> |
The pepper is OBLETH_API_KEY_PEPPER (Helm obleth.apiKeyPepper). With it
set, a copy of the database alone cannot confirm guessed keys. Adding,
changing or removing it invalidates every secret key issued before, and a
restored backup authenticates only on a gateway with the same pepper.
Each key has a kind:
kind | Credential | Created by |
|---|---|---|
secret | The sk_ key | The dashboard or POST /api/v1/tenants/{id}/keys |
identity | An identity token; there is no secret | The gateway, on the first verified token for a subject |
Both kinds carry the same tenant, weight, max_in_flight, budget, model list
and usage. What each key action does to authentication:
| Action | Endpoint | Effect |
|---|---|---|
| Disable or re-enable | PUT /api/v1/keys/{id}/disabled | 403 api key disabled while disabled |
| Delete | DELETE /api/v1/keys/{id} | 401 invalid api key; an identity key is re-created on the next token unless jit_provision is false |
| Move to another tenant | PUT /api/v1/keys/{id}/tenant | Same secret; requests count against the new tenant |
The dashboard server holds one reserved key of its own, in a reserved tenant that is hidden from key lists and cannot be edited through the API. Every endpoint is in the Management API reference.
Identity tokens (JWT)
With OBLETH_JWT_ISSUERS set, the data plane accepts short-lived JWT access
tokens from the issuers it lists. A verified token maps to an identity key,
an api_keys row with kind = 'identity' and key_hash =
jwt: + SHA-256 of issuer, a zero byte, and subject. From then on the request
is handled like one with a secret key.
| Check | Rule |
|---|---|
| Issuer | iss must equal a configured issuer |
| Audience | aud must contain the issuer's audience |
| Required claims | exp, aud, iss; the header needs a kid |
| Time | exp, nbf and iat checked with 60 s of clock skew |
| Signature | Against the issuer's JWKS, with an allowed algorithm |
| Subject | The subject_claim must be a non-empty string |
OBLETH_JWT_ISSUERS is a JSON array read once at start. A parse error, a
duplicate issuer, a disallowed algorithm, or a failed discovery fetch stops the
gateway from starting. Each entry takes:
| Field | Default | Meaning |
|---|---|---|
issuer | required | http(s) URL; exact match on iss |
audience | required | Exact match on aud |
tenant | required | Tenant name for new identity keys; created if missing |
jwks_url | jwks_uri from <issuer>/.well-known/openid-configuration | Where signing keys are fetched |
algorithms | ["RS256", "ES256"] | Any of RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, EdDSA |
subject_claim | sub | Claim that identifies the user |
device_claim | unset | Claim copied to the usage row's device_id when it is 1 to 64 characters of A-Z a-z 0-9 _ - |
identity_claims | [] | Extra claims stored in api_keys.identity_claims |
jit_provision | true | false rejects a subject with no identity key with 401 |
Signing keys are cached per issuer and refreshed every 15 minutes. A token with
an unknown kid triggers a refetch, at most one per issuer per minute. The
discovery and JWKS URLs pass the same outbound-URL policy as model upstreams.
Outcomes are counted in obleth_jwt_verify_total{result} and
obleth_jwks_refresh_total{issuer_index,result}
(Prometheus metrics).
Management API
Every request on port 9180 carries the admin token:
curl http://localhost:9180/api/v1/tenants \
-H "Authorization: Bearer $OBLETH_ADMIN_TOKEN"
The whole header must equal Bearer <OBLETH_ADMIN_TOKEN>, compared in
constant time; x-api-key is not accepted. A missing or wrong token returns
401 with {"error": "unauthorized"}. The token has no default: the gateway
does not start without it. It grants full access, with no per-user roles.
GET /api/v1/health, GET /api/v1/version and GET /api/v1/openapi.json
need no token. Keep port 9180 private (Security).
Dashboard
The dashboard has its own sign-in; browsers never see the admin token. Its
server calls the Management API with OBLETH_ADMIN_TOKEN.
| Part | How it works |
|---|---|
| Accounts and sessions | Dashboard auth tables in Postgres (DATABASE_URL) |
| Session signing | BETTER_AUTH_SECRET, or DASHBOARD_SESSION_SECRET when unset; at least 32 characters |
| Break-glass admin | Seeded from DASHBOARD_ADMIN_EMAIL and DASHBOARD_PASSWORD (at least 8 characters) when no active admin exists |
| Email sign-up | Closed; accounts come from SSO or the break-glass admin |
| SSO | OIDC_PROVIDERS (Dashboard SSO) |
| Allowed origins | BETTER_AUTH_URL plus TRUSTED_ORIGINS (comma-separated; * trusts all) |
| Roles | admin, or user (a portal user tied to one tenant) |
A new SSO user starts as a pending user until an admin activates them on the
Users page. The seed never re-promotes an existing account with the same email.
No admin can demote or deactivate themselves or the last active admin.
Key lookup path
The gateway hashes the presented key and looks it up in an in-process cache
(entries live up to 300 seconds; invalidation messages
drop them early), then in Redis. The data plane does not read Postgres to
authenticate a secret key. The Management API writes Redis on every change,
and the gateway re-pushes keys from Postgres at start, every
OBLETH_REDIS_REWARM_SECS, and after a Redis reconnect. The first token for a
new identity writes its key to Postgres once, then warms Redis and the cache.
