> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orq.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Workload federation

> Exchange an OIDC token from a CI pipeline or service for a short-lived Orq.ai API key, so no Orq.ai secret is stored in the workload.

<Badge color="blue" size="lg" shape="pill" stroke="true">Feature available with the [Enterprise Plan](https://orq.ai/solutions/enterprise)</Badge>

Workload federation lets an external workload exchange the OIDC token issued by its own identity provider for a short-lived **Orq.ai** API key. No **Orq.ai** secret is stored in the workload. The key is scoped to one project, carries only the granted permissions, and expires automatically.

The name describes what is federated: the identity of a *workload* (a process that runs without a person), not the identity of the workspace. **Orq.ai** is the side that trusts the external issuer here.

Related pages:

* [Single Sign-On](/ai-studio/organization/sso) covers member login with OIDC or SAML and SCIM provisioning. Workload federation is for machine identities, not for people signing in.
* [Session identity tokens](/ai-studio/ai-engineering/agent-sessions/session-identity) covers the reverse direction, where **Orq.ai** issues OIDC tokens that AWS, Google Cloud, Vault, and other relying parties verify.

## Use cases

A workload is any process that calls **Orq.ai** with no person driving it, authenticating with an identity its own platform already issues. It is not an **Orq.ai** API key, and it is not a person signed in to the dashboard. A CI/CD job, a scheduled batch job, a backend service, a long-running application, or a deploy pipeline all qualify.

<Note>
  The issuer's discovery document must be reachable from the public internet, so an in-cluster Kubernetes issuer works only when it is published that way.
</Note>

If the process would otherwise need an **Orq.ai** credential stored and rotated, it is a candidate for federation. If a person signs in, use [Single Sign-On](/ai-studio/organization/sso).

Each case below needs one trust configuration: an issuer, claim rules that narrow who may exchange, and the permissions the job uses.

* **Run evaluations from CI.** A GitHub Actions workflow on one repository's `main` branch, bound to `repository_id` and `ref`.
* **Sync Prompts from a pipeline.** A GitLab CI pipeline bound to `project_id` and `ref`.
* **Call the AI Gateway from a service.** A backend service behind Keycloak, Okta, Auth0, or Microsoft Entra ID, bound to its `azp` or `appid`.
* **Reach the production Deployment only from a protected environment.** A pipeline bound to `repository_id` and `environment`.
* **Export usage on a schedule.** A `Read only` trust that cannot create or change anything.

[Set up GitHub Actions step by step](#set-up-github-actions-step-by-step) walks the first case end to end, and every granted exchange is recorded under **Settings** > **Organization** > **Audit logs**.

## How it works

1. The workload asks its identity provider for an OIDC token with a specific `aud` (audience) value.
2. The workload sends that token to the **Orq.ai** token exchange endpoint.
3. **Orq.ai** finds the trust configuration matching the token's issuer and audience, verifies the signature against the issuer's published keys, and checks the token's claims against the configured rules.
4. **Orq.ai** returns a short-lived API key limited to the permissions and project on the trust configuration.
5. The workload uses the key as a normal Bearer token until it expires.

The only values the two sides have to agree on are public: the issuer URL and the audience. The subject token itself stays the credential and is sent only to the exchange endpoint.

## Prerequisites

* A workspace on the Enterprise plan. Configuring SSO first is not required.
* Workspace admin rights to manage trust configurations.
* An identity provider that issues OIDC JWTs and publishes its signing keys through OpenID discovery. The discovery document and signing keys must be reachable from the public internet.
* Workload federation is tested with **GitHub Actions** (including GitHub Enterprise Server) and **Keycloak**. Other providers such as **GitLab CI**, **Okta**, **Auth0**, and **Microsoft Entra ID** work when their tokens meet the requirements under [Exchange a token](#exchange-a-token).

## Create a trust configuration

Go to **Settings** > **Organization** > **Workload federation** and click **Add trust configuration**.

| Field | Description |
| - | - |
| **Name** | A label, for example `GitHub Actions CI`. |
| **Issuer URL** | The OIDC issuer of the identity provider, exactly as it appears in the token's `iss` claim. Must use `https` and have no trailing slash. Examples: `https://token.actions.githubusercontent.com` for GitHub Actions, `https://<github-host>/_services/token` for GitHub Enterprise Server. |
| **Audience** | The value the workload must request as `aud`. The field is prefilled with a unique value; keep it unless the identity provider requires a specific one. Copy it into the workload. It cannot be changed after creation; for a different audience, create a new trust configuration. |
| **Expires after** | Lifetime of each minted key, 1 to 60 minutes. Default 60 minutes. |
| **Project** | Every minted key is pinned to this project. |
| **Permissions** | `All`, `Restricted`, or `Read only`. Restricted opens the same permission picker used for API keys, limited to the permissions a project-scoped key can hold. Workspace administration permissions (members, billing, SSO, groups, workspace settings) and identity permissions (identities, SCIM) cannot be granted to a workload. |
| **Claim rules** | Which tokens from this issuer are accepted. See [Claim rules](#claim-rules). |

An existing configuration can be disabled from its edit view to stop exchanges without deleting it.

<Note>
  An issuer and audience pair can belong to only one enabled trust configuration across all of **Orq.ai**. If a pair is already in use, the configuration is refused; keep the generated audience to avoid this.
</Note>

<Warning>
  Any change to a trust configuration, including renaming it, changing its lifetime, or turning it off, revokes every key it has already minted. Deleting a trust configuration revokes all of its keys. Workloads obtain a new key on their next exchange.
</Warning>

## Set up GitHub Actions step by step

This walkthrough lets the `main` branch of the `acme/evals` repository call the **AI Gateway**.

1. Find the repository's numeric ID using any of these:

   * **Browser (public repositories):** open `https://api.github.com/repos/acme/evals` and copy the top-level `"id"` value.
   * **GitHub CLI (public or private):** `gh api repos/acme/evals --jq .id`
   * **From a workflow run:** add a step with `echo "${{ github.repository_id }}"`. Use `github.repository_owner_id` for the organization ID.

   Example result: `812345678`. Replace `acme/evals` with the target repository.

2. In **Orq.ai**, go to **Settings** > **Organization** > **Workload federation**, click **Add trust configuration**, and fill in:

   | Field | Value |
   | - | - |
   | **Name** | `acme/evals main` |
   | **Issuer URL** | `https://token.actions.githubusercontent.com` |
   | **Audience** | Keep the generated value and copy it |
   | **Expires after** | `60` (the default) |
   | **Project** | The project the workflow works in, for example `Evals` |
   | **Permissions** | `Restricted`, with **Chat completions** granted |
   | **Claim rules** | One rule with two conditions: `repository_id` = `812345678` and `ref` = `refs/heads/main` |

3. In the GitHub repository, go to **Settings** > **Secrets and variables** > **Actions** > **Variables** and add a repository variable `ORQ_AUDIENCE` with the copied audience. The audience is not a secret, so a variable is enough.

4. Add the workflow from [GitHub Actions](#github-actions) to `.github/workflows/`.

5. Push to `main`. The job exchanges its GitHub token for an **Orq.ai** API key and calls the **AI Gateway**. The exchange appears in **Settings** > **Organization** > **Audit logs** as `federation.exchange.granted`, with the token subject.

If the exchange step fails with `invalid_target`, see [Troubleshooting](#troubleshooting).

## Claim rules

A rule is a list of conditions. A token is accepted when **every condition of any single rule** matches. Rules are combined with OR, conditions within a rule with AND.

Each condition names a claim and lists its allowed values:

* A value without `*` must match the claim exactly.
* A value containing `*` is a glob anchored at both ends. `*` matches any characters except `:`, so `repo:acme/*:ref:refs/heads/main` cannot be widened by a token whose `sub` contains extra `:` segments.
* A missing claim, or a claim that is not a string, fails the condition.

A trust configuration holds up to 20 rules, each with up to 20 conditions, each with up to 50 values.

Rules decide which tokens are accepted. The issuer and audience only prove that the token came from the right provider; the rules narrow it down to a specific **repository**, **branch**, **environment**, **user**, or **application**.

<Warning>
  A rule that is too broad hands out keys to everything that provider signs tokens for. Start narrow and widen only when needed.
</Warning>

### Which claims to use

Every OIDC provider puts different claims in its tokens. The ones that identify a workload or a person:

| Provider | Claims commonly used in rules |
| - | - |
| GitHub Actions | `repository_id`, `repository_owner_id`, `repository`, `ref`, `environment`, `actor`, `event_name`, `workflow` |
| GitLab CI | `project_id`, `namespace_path`, `ref`, `user_login` |
| Keycloak, Okta, Auth0 | `sub`, `preferred_username`, `email`, `azp` |
| Microsoft Entra ID | `sub`, `preferred_username`, `upn`, `appid` |

To see the exact claims and values a provider sends, decode one of its tokens before writing rules:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | . + "=" * ((4 - length % 4) % 4) | @base64d | fromjson' <<< "${TOKEN}"
```

<Note>
  A live subject token is the credential itself, so decode it locally. An already-expired token is safe to paste into an online decoder such as [jwt.io](https://jwt.io), and nothing needs decoding on the **Orq.ai** side: the exchange endpoint verifies the signature itself.
</Note>

Claim names are case-sensitive, and a rule on a claim the provider does not send never matches.

### Common rules

| Goal | Conditions in one rule |
| - | - |
| Only one repository, any branch | `repository_id` = `123456789` |
| Only the main branch of one repository | `repository_id` = `123456789` and `ref` = `refs/heads/main` |
| Only release tags of one repository | `repository_id` = `123456789` and `ref` = `refs/tags/v*` |
| Only the `production` environment of one repository | `repository_id` = `123456789` and `environment` = `production` |
| Only workflows triggered by one person | `repository_id` = `123456789` and `actor` = `jane-doe` |
| Every repository in one GitHub organization | `repository_owner_id` = `987654` |
| Only one Keycloak or Okta user | `email` = `jane@example.com` |
| Only one Keycloak or Okta application | `azp` = `ci-runner` |

Conditions in the same rule are ANDed, so each row above accepts only tokens that satisfy every condition in it.

* **To allow alternatives**, add a rule per alternative. To accept two repositories, create two rules, each binding its own `repository_id`; a token from either repository matches one of them and is accepted.
* **To allow several values in one condition**, list them. `ref` with `refs/heads/main` and `refs/heads/release` accepts a token from either branch.

### GitHub Actions rules

GitHub Actions tokens have two additional requirements.

**Bind a numeric ID.** Every rule must bind `repository_id` or `repository_owner_id` to a value without `*`. Repository and organization names can be renamed or transferred; the numeric IDs cannot. To find them:

* Open `https://api.github.com/repos/<owner>/<repo>` for a public repository, or run `gh api repos/<owner>/<repo> --jq .id`. The organization ID is `.owner.id` in the same response.
* Inside a workflow, `${{ github.repository_id }}` and `${{ github.repository_owner_id }}` print the exact values the token carries.

**Restrict the events.** These events are accepted:

* `push`, `workflow_dispatch`, `schedule`, `release`, `create`, `deployment`, `deployment_status`.

Any other event, such as `pull_request` from a fork or a merge queue, is rejected unless every rule has an `event_name` condition with exact values. A value containing `*` does not count.

<Note>
  To accept pull request workflows on purpose, add an `event_name` condition to each rule and list every accepted event, for example `push` and `pull_request`. A rule with only `event_name` = `pull_request` rejects tokens from pushes.
</Note>

## Exchange a token

The exchange endpoint implements OAuth 2.0 Token Exchange ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)).

```
POST https://my.orq.ai/v2/auth/federation/token
Content-Type: application/x-www-form-urlencoded
```

| Parameter | Value |
| - | - |
| `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange` |
| `subject_token_type` | `urn:ietf:params:oauth:token-type:jwt` or `urn:ietf:params:oauth:token-type:id_token` |
| `subject_token` | The OIDC token from the identity provider |
| `audience` | The audience shown on the trust configuration |

No **Orq.ai** credential is sent. The subject token is the credential.

A successful response returns the key:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "access_token": "sk-orq-...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 3600
}
```

`expires_in` is the lifetime configured on the trust configuration, independent of the subject token's own expiry.

The subject token must:

* Be signed by the issuer's current keys, with `iss` equal to the trust configuration's issuer URL.
* Contain the trust configuration's audience in `aud`.
* Have been issued (`iat`) within the last 10 minutes.
* Carry a `jti` claim. Each token can be exchanged once.

### Errors

| HTTP | `error` | Meaning |
| - | - | - |
| 400 | `invalid_request` | A parameter is missing or malformed. |
| 400 | `invalid_target` | The token was not accepted: no matching trust configuration, signature or audience mismatch, claim rules not met, token stale or already exchanged, or workspace not entitled. The specific reason is not returned. See [Troubleshooting](#troubleshooting). |
| 429 | `slow_down` | More than 60 exchanges per minute on one trust configuration. |
| 503 | `temporarily_unavailable` | **Orq.ai** could not complete the exchange. Retry with a new subject token. |

## Use the key

The minted key is a standard **Orq.ai** API key.

* **Send it** as `Authorization: Bearer <key>` to the platform API, the **AI Gateway**, and an **MCP Gateway**.
* **Scope**: the granted permissions and the pinned project only.
* **Lifetime**: it stops working when it expires.
* **Audit**: each grant is recorded as `federation.exchange.granted`, with the trust configuration, the token subject, the key ID, and its expiry.

## Examples

### GitHub Actions

```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}}
name: call-orq

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  run:
    runs-on: ubuntu-latest
    env:
      ORQ_AUDIENCE: ${{ vars.ORQ_AUDIENCE }}
    steps:
      - name: Exchange the GitHub token for an Orq.ai API key
        run: |
          subject_token=$(curl -sS \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${ORQ_AUDIENCE}" \
            -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" | jq -r .value)

          response=$(curl -sS -X POST https://my.orq.ai/v2/auth/federation/token \
            --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
            --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
            --data-urlencode "subject_token=${subject_token}" \
            --data-urlencode "audience=${ORQ_AUDIENCE}")

          orq_api_key=$(jq -r '.access_token // empty' <<< "${response}")
          if [ -z "${orq_api_key}" ]; then
            echo "Token exchange failed: $(jq -r '.error // .error_description // "unparseable exchange response"' <<< "${response}" 2>/dev/null || echo "unparseable exchange response")"
            exit 1
          fi

          echo "::add-mask::${orq_api_key}"
          echo "ORQ_API_KEY=${orq_api_key}" >> "$GITHUB_ENV"

      - name: Call the AI Gateway with the Orq.ai CLI
        env:
          ORQ_NO_INPUT: 1
        run: |
          npm install -g @orq-ai/cli
          orq chat create \
            --model openai/gpt-5.4-mini \
            --messages '[{"role": "user", "content": "Write a professional email"}]' \
            -o json -j 'choices[0].message.content' --raw
```

The exchange step exports the minted key as `ORQ_API_KEY`, so every later step uses it exactly as it would a stored API key.

* **The CLI** reads `ORQ_API_KEY` directly. `ORQ_NO_INPUT=1` makes it fail instead of prompting, so a misconfigured job errors out rather than hanging.
* **The SDKs** read the same variable. The step can just as well be a script using the Node SDK (`@orq-ai/node`) or the Python SDK (`orq-ai-sdk`).
* **`id-token: write` is required.** Without it, GitHub does not issue an OIDC token to the job.

`ORQ_AUDIENCE` is the repository variable from [Set up GitHub Actions step by step](#set-up-github-actions-step-by-step).

<Note>
  The trust configuration for this workflow uses issuer `https://token.actions.githubusercontent.com`, one rule with `repository_id` set to the repository's numeric ID and `ref` set to `refs/heads/main`, and grants the **Chat completions** permission.
</Note>

### Keycloak

**Orq.ai** reads the audience from the **ID token**, not the access token.

1. In Keycloak, open the client the workload uses, then make the ID token carry the audience:

   * **Simplest:** create the trust configuration with the client ID as its audience, because Keycloak already puts the client ID in the ID token's `aud`.
   * **Explicit:** add an **Audience** mapper to the client with **Included Custom Audience** set to the trust configuration's audience and **Add to ID token** turned on.
2. Create a trust configuration with issuer `https://<keycloak-host>/realms/<realm>` and a rule that limits who may exchange, for example `azp` = `<client-id>` for one application or `email` = `jane@example.com` for one user.
3. Request tokens with `scope=openid` so Keycloak returns an ID token, then exchange it:

   ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
   curl -sS -X POST https://my.orq.ai/v2/auth/federation/token \
     --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
     --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:id_token" \
     --data-urlencode "subject_token=${ID_TOKEN}" \
     --data-urlencode "audience=${ORQ_AUDIENCE}"
   ```

### Other OIDC providers

Request an ID token from the provider with the trust configuration's audience in `aud`, then send it to the exchange endpoint as shown above. Add a rule on a claim such as `preferred_username`, `email`, or `sub` to limit which principals may exchange tokens.

## Troubleshooting

Every token rejection returns the same `invalid_target` error. Open **Settings** > **Organization** > **Audit logs** and look for `federation.exchange.denied` on the trust configuration. The `reason` names what to fix:

| Reason | Fix |
| - | - |
| `claims_rejected` | The token does not satisfy any rule. Decode the token and compare its claims with the rules. For GitHub Actions, also check the event: see [GitHub Actions rules](#github-actions-rules). |
| `subject_token_stale` | The token was issued more than 10 minutes ago. Request a new token right before the exchange. |
| `subject_token_replayed` | The token was already exchanged. Request a new token for each exchange. |
| `missing_jti` | The provider does not include a `jti` claim. Configure it to, or use a different token type. |
| `trust_config_superseded` | The trust configuration changed while the exchange was running. Request a new token and retry. |
| `trust_config_ttl_invalid` | The configured lifetime is below 30 seconds. Raise **Expires after** to between 1 and 60 minutes. |

If no denied entry appears, **Orq.ai** could not link the token to a trust configuration, could not verify it, or the per-trust denial-audit budget was exhausted. Check that:

* The token's `iss` exactly matches the **Issuer URL**, including the scheme and without a trailing slash.
* The token's `aud` contains the trust configuration's audience, and the same audience is sent in the `audience` parameter.
* The issuer's discovery document and signing keys are reachable from the public internet, and the token is signed with one of those keys.
* The trust configuration is enabled.
* The workspace is on the Enterprise plan.

## Security

* Token rejections return the same generic error, so a caller cannot discover which issuers or audiences are configured.
* Each subject token can be exchanged once. Replays are rejected.
* Subject tokens older than 10 minutes are rejected regardless of their own expiry.
* Exchanges are limited to 60 per minute per trust configuration.
* Granted exchanges, and denials of correctly signed tokens meant for a trust configuration, are recorded in the workspace audit log. Denials are capped at 20 per minute per trust configuration.
* Keys minted from a configuration created in the dashboard are pinned to its project, and no minted key can hold workspace administration permissions.
* Issuer discovery and key fetches refuse redirects.

## Limitations

* A workspace holds up to 100 trust configurations.
* Trust configurations are managed by workspace admins from the dashboard. They cannot be created or edited with an API key.
* The trust configuration's audience must appear in the token's `aud`. If the identity provider cannot add a custom value, use a value it already sends, such as the client ID, as long as no other trust configuration uses the same issuer and audience.
* Identity providers on a private network are not supported. **Orq.ai** must be able to fetch the issuer's discovery document and signing keys over the public internet.
