Console
Authentication

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:

FieldMeaning
issuerThe token's iss, an https URL. For GitHub Actions: https://token.actions.githubusercontent.com.
audienceA value that the token's aud must hold. You choose the value, and you request tokens for it.
subject_patternA pattern that the token's sub must match. * matches any run of characters. ? matches one character. The pattern matches the whole sub.
required_claimsClaims 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_secondsHow long the key works: 300 to 86400 seconds. Default 3600.
workspace and principalWhere 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:

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:

json
{
  "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), nbf and iat, with 60 seconds of clock skew.
  • aud holds the trust's audience. aud can be a string or a list.
  • sub matches the trust's pattern.
  • Each required claim equals the token's claim.
  • The token was not exchanged before. A token (its iss and jti) works one time.

Errors

StatusBody
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:

ReasonMeaning
malformedThe token is not a valid JWT, is longer than 16,384 characters, or has no exp or https iss.
unsupported_algThe algorithm is not on the accepted list.
unknown_identityNo enabled trust exists for this issuer, or for this identity_id.
issuer_unavailableRouterPlus could not read the issuer's discovery document or key set.
signatureThe signature does not verify with the issuer's keys.
expired, not_yet_valid, issued_in_futureA time check failed.
not_trustedNo trust matched the audience, subject and required claims.
replayedThis token was already exchanged.

GitHub Actions example

  1. 01
    Set up a trust with issuer https://token.actions.githubusercontent.com and audience routerplus.
  2. 02
    Set a subject pattern, for example repo:acme/app:ref:refs/heads/main.
  3. 03
    Add repository_owner: acme as a required claim.
  4. 04
    Give the job the id-token: write permission.
yaml
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