Identity tokens
Let callers use short-lived JWTs from your identity provider instead of API keys, with a per-user key created on first use.
5 min readUpdated for v2.4.0Edit on GitHub
OBLETH_JWT_ISSUERS, then restart the gatewayA caller sends the token wherever an API key goes: Authorization: Bearer … or x-api-key. The gateway verifies it offline against the issuer's published public keys (JWKS), maps the issuer and subject to an identity key, and from then on treats the request like one made with a key: tenant status, schedules, budgets, model allowlists and guardrails all apply. This works on every data-plane route, including /v1/messages and /mcp/<server>.
Turn it on
- 1
Describe the issuer
Set
OBLETH_JWT_ISSUERSto a JSON array with one entry per issuer you trust:[ { "issuer": "https://idp.example.com", "audience": "obleth", "tenant": "cli-users", "algorithms": ["ES256"], "device_claim": "device_id", "identity_claims": ["uid"] } ]On Kubernetes, set
obleth.jwtIssuersin your values. Withobleth.existingSecret, put the JSON under theOBLETH_JWT_ISSUERSkey of that Secret. On Compose, set it indeploy/docker/.env. - 2
Restart the gateway
The variable is read once, at startup. On Compose, run
docker compose -f deploy/docker/docker-compose.yml up -d obleth. The Helm chart rolls the pods when its own Secret changes; after editing an existing Secret, roll the deployment yourself. - 3
Check the logs
INFO obleth: jwks loaded issuer=https://idp.example.com INFO obleth: jwt bearer auth enabled issuers=1Neither line means the variable is empty, and every credential is treated as an API key.
jwks not loaded at bootmeans the key document could not be fetched; that issuer's tokens fail until a refresh succeeds. An invalid configuration stops the gateway from starting. - 4
Send a token
curl -s https://gateway.example.com/v1/chat/completions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"model": "my-model", "messages": [{"role": "user", "content": "ping"}]}'A 200 means the token verified and the user's identity key exists. It shows on the API Keys page as
identity · <subject>. A 401 carries no detail; see Troubleshooting.
Issuer settings
| Field | Default | Meaning |
|---|---|---|
issuer | required | Must equal the token's iss exactly |
audience | required | Must appear in the token's aud |
tenant | required | The tenant identity keys are created under |
jwks_url | discovered | The key document. Without it, jwks_uri is read from <issuer>/.well-known/openid-configuration at startup |
algorithms | ["RS256", "ES256"] | Allowed signing algorithms. Asymmetric only; HS* stops the gateway at startup |
subject_claim | sub | The claim that names the user |
device_claim | none | A claim recorded as device_id on usage rows (letters, digits, _, -; up to 64 characters) |
identity_claims | [] | Extra claims stored on the identity key when it is created |
jit_provision | true | Create the identity key on first use. With false, unknown users get 401 |
If the named tenant does not exist, it is created with default policy and an alert is raised. Where your account system already manages that tenant, treat the alert as a misconfiguration.
What a token must carry
- A compact JWS (three base64url parts starting
eyJ) of at most 8,192 bytes. Anything else is looked up as an API key. - A header
algfrom the issuer'salgorithms, and akid. Keys are looked up bykidalone. issandaudmatching the issuer entry, a non-empty string subject, andexp.nbfandiatare checked when present, with 60 seconds of clock skew.
If your provider is a full OIDC server (Keycloak, Okta, Entra, Auth0, Dex), create a client whose audience is the configured value and you are done. If you sign tokens yourself, generate an ES256 or RS256 keypair, keep the private key in your own secret store, publish the public key with a kid at an unauthenticated JWKS URL, and sign short-lived tokens (minutes to hours) with iss, aud, sub and exp. The gateway never receives the private key.
Whoever holds the signing key can mint a token for any subject, and with jit_provision on the gateway creates a key for it. Protect it like the admin token.
Identity keys
Identity keys have no secret, so there is nothing to copy or rotate. Disable one to block that user at once, ahead of the token's expiry. Deleting one only resets it: the user's next token recreates it with fresh budgets. Anything scoped to a key is scoped to the user, such as who can see a video job.
The Management API returns kind, identity_issuer, identity_subject and identity_claims on each key; join external account systems on identity_subject. Request logs show the device_id of identity-key traffic, and GET /api/v1/usage/logs filters on device_id.
Rotating the signing key
The gateway refreshes each issuer's keys every 15 minutes, and a token with an unknown kid triggers at most one extra fetch a minute. If a refresh fails, the previous keys keep working.
- Generate a new keypair with a new
kid. - Publish both public keys and wait for the gateway to fetch them.
- Sign new tokens with the new key.
- Remove the old public key once every token it signed has expired.
Troubleshooting
Every verification failure returns 401 invalid token. A verified user whose key cannot be created gets 503 identity provisioning unavailable. Read the reason from the metrics port:
curl -s http://127.0.0.1:9091/metrics | grep obleth_jwt
obleth_jwt_verify_total{result} | Cause |
|---|---|
unknown_issuer | iss matches no configured issuer. Compare exactly, trailing slash included |
bad_alg | The header's alg is not allowed for this issuer |
unknown_kid | No kid, or no loaded key has it |
bad_signature | The key with that kid did not verify the token |
expired, not_yet_valid | Outside exp or nbf; check clock drift |
bad_audience | aud is missing or lacks the configured audience |
bad_subject | The subject claim is missing, empty or not a string |
malformed | Not a parseable JWT, or over 8,192 bytes |
not_provisioned | Verified, but unknown and jit_provision is false |
provision_failed | Verified, but the key could not be created (503, and an alert) |
not_provisioned and provision_failed are counted as well as ok. If no counter moves at all, the credential never reached the verifier: the feature is off, or the value does not look like a JWT. obleth_jwks_refresh_total{issuer_index, result} counts key-document fetches; a steady error rate means tokens signed by any newly rotated key will fail.
Why a token cannot be forged
Anyone can generate a keypair, but the gateway trusts only the keys in the JWKS documents of the issuers you listed. A token signed with any other key fails with unknown_kid or bad_signature. Forging one needs the issuer's private key, write access to its JWKS, or access to the gateway's environment, each of which already means control of the system. Symmetric HS* algorithms are refused at startup, so a public key can never be replayed as an HMAC secret, and none is never accepted. The JWKS URL is subject to the same outbound address policy as model backends.
