Workload identity: keys without stored secrets
Exchange an OIDC token for a short-lived key.
A workload sends an OIDC token from its platform and gets a short-lived RouterPlus inference key back. You store no RouterPlus key in CI or in the cluster.
Any issuer that publishes OIDC discovery and a key set over https works. Examples are GitHub Actions, Google Cloud, AWS and Kubernetes service account tokens.
Trusts
Set up one trust for each workload with the management API. A trust has these fields:
| Field | Meaning |
|---|---|
issuer | The token's iss, an https URL. For GitHub Actions: https://token.actions.githubusercontent.com. |
audience | A value that the token's aud must hold. You choose the value, and you request tokens for it. |
subject_pattern | A pattern that the token's sub must match. * matches any run of characters. ? matches one character. The pattern matches the whole sub. |
required_claims | Claims the token must carry, as {"claim": "value"}. RouterPlus compares values as text. Use a dotted path for a nested claim, for example kubernetes.io.namespace. |
key_ttl_seconds | How long the key works: 300 to 86400 seconds. Default 3600. |
| workspace and principal | Where the key's requests and spend go. |
Each trust has an identity_id. A disabled trust accepts no tokens.
Exchange endpoint
POST https://demo-api.routerplus.com/api/v1/auth/token (also POST /v1/auth/token)
Request body, JSON:
{"token": "<OIDC JWT>", "identity_id": "<trust id, optional>"}The endpoint also accepts the RFC 8693 form (application/x-www-form-urlencoded): grant_type=urn:ietf:params:oauth:grant-type:token-exchange, subject_token=<JWT>, subject_token_type=urn:ietf:params:oauth:token-type:jwt (or ...:id_token).
Without identity_id, RouterPlus tries the enabled trusts for the token's issuer, oldest first. The first trust that matches makes the key.
Response 200:
{
"access_token": "tm_vk_...",
"token_type": "Bearer",
"expires_in": 3600,
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}Send access_token as a Bearer token on inference calls. It stops working after expires_in seconds. To get a new key, exchange a new token.
What RouterPlus checks
- The signature, with the issuer's published keys. Accepted algorithms: RS256, RS384, RS512, PS256, ES256, ES384. RouterPlus refuses
none, HS256, HS384 and HS512. exp(required),nbfandiat, with 60 seconds of clock skew.audholds the trust's audience.audcan be a string or a list.submatches the trust's pattern.- Each required claim equals the token's claim.
- The token was not exchanged before. A token (its
issandjti) works one time.
Errors
| Status | Body |
|---|---|
400 | {"error": "invalid_request", "error_description": "..."}: no token, a bad identity_id, or a bad grant_type or subject_token_type. |
401 | {"error": "invalid_token", "error_description": "Token rejected: <reason>."} |
405 | {"error": "invalid_request", ...}: the method is not POST. |
503 | {"error": "temporarily_unavailable", ...}: retry. |
The 401 reasons:
| Reason | Meaning |
|---|---|
malformed | The token is not a valid JWT, is longer than 16,384 characters, or has no exp or https iss. |
unsupported_alg | The algorithm is not on the accepted list. |
unknown_identity | No enabled trust exists for this issuer, or for this identity_id. |
issuer_unavailable | RouterPlus could not read the issuer's discovery document or key set. |
signature | The signature does not verify with the issuer's keys. |
expired, not_yet_valid, issued_in_future | A time check failed. |
not_trusted | No trust matched the audience, subject and required claims. |
replayed | This token was already exchanged. |
GitHub Actions example
- 01Set up a trust with issuer
https://token.actions.githubusercontent.comand audiencerouterplus. - 02Set a subject pattern, for example
repo:acme/app:ref:refs/heads/main. - 03Add
repository_owner: acmeas a required claim. - 04Give the job the
id-token: writepermission.
permissions:
id-token: write
contents: read
jobs:
eval:
runs-on: ubuntu-latest
steps:
- name: Get a RouterPlus key
run: |
OIDC=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=routerplus" | jq -r .value)
KEY=$(curl -sS https://demo-api.routerplus.com/api/v1/auth/token \
-H "Content-Type: application/json" \
-d "{\"token\":\"$OIDC\",\"identity_id\":\"YOUR_IDENTITY_ID\"}" | jq -r .access_token)
echo "::add-mask::$KEY"
echo "TM_API_KEY=$KEY" >> "$GITHUB_ENV"
- name: Call a model
run: |
curl -sS https://demo-api.routerplus.com/v1/chat/completions \
-H "Authorization: Bearer $TM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"MODEL_ID","messages":[{"role":"user","content":"Hello"}]}'GitHub Actions subjects have one of these forms:
repo:<owner>/<repo>:ref:refs/heads/<branch>repo:<owner>/<repo>:environment:<name>repo:<owner>/<repo>:pull_request
For agents: /docs/workload-identity.md and /llms.txt