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

# Workload Identity Federation

> Let CI jobs and cloud workloads act as a service account with their own short-lived tokens, without storing an API key

Workload identity federation lets a program running outside TextQL, such as a GitHub Actions job, a Cloud Run service or a Kubernetes pod, authenticate as a [service account](https://docs.textql.com/core/admin/api-keys.md#key-types) using the token its platform already gives it. There is no API key to create, store as a secret, or rotate.

The workload trades its platform token for a TextQL access token that lasts at most one hour. TextQL accepts the trade only when the token comes from an issuer you registered and matches a federation rule you wrote.

> [!NOTE]
>
> Workload identity federation is in beta and off by default. An organization admin turns it on under **Settings → Security → Authentication → Token exchange (Beta)**. The same switch controls member token exchange.

## How It Works

1. You **register an issuer**: the system that signs your workload's tokens, such as `https://token.actions.githubusercontent.com`.
2. You **create a federation rule** on that issuer: the audience the token must carry, the claims it must have (for example "this repository, this branch"), and the service account it then acts as.
3. The workload gets a token from its platform and sends it to `POST /oauth/token`. TextQL checks the signature against the issuer's keys, checks the token's lifetime, finds the one rule the token matches, and returns an access token for that rule's service account.
4. The workload calls TextQL with that access token until it expires, then exchanges a new platform token.

The access token acts as the service account in full, bounded by the access the rule grants (read only, or read and write). Everything it does is attributed to the service account in the [audit log](https://docs.textql.com/core/admin/audit-log.md).

## Set It Up

Open **Settings → Developers → Workload Identity** and click **Connect Workload**. The wizard walks through four steps:

1. **Issuer.** Pick an issuer you already registered, or choose a provider (GitHub Actions, AWS, Google Cloud, Microsoft Entra, Kubernetes or Custom OIDC) to register one.
2. **Federation rule.** Name the rule, confirm the audience, and add claim conditions. Each provider suggests the conditions that identify your workload.
3. **Target.** Pick the service account, the access level, and how long issued tokens last (1 minute to 1 hour).
4. **Test.** Copy the setup snippet for your provider, and optionally paste a real token from your workload to see whether it would be accepted.

You need the `organization:write` permission to register issuers and manage rules. Members with `organization:read` can view them.

When you create an API key for a service account and workload identity is available, TextQL first suggests setting up federation instead.

## Issuers

| Field | Meaning |
| - | - |
| **Name** | A label for the issuer, lowercase letters, numbers and hyphens. |
| **Issuer URL** | Must equal the token's `iss` claim. It cannot be changed after registration. |
| **Signing keys** | **OIDC discovery** reads `/.well-known/openid-configuration` under the issuer URL. **Explicit URL** points at a JWKS document directly. **Inline keys** stores the public keys, for issuers TextQL cannot reach, such as a private Kubernetes cluster. |
| **Custom CA** | A PEM certificate bundle to trust when fetching discovery or keys from an issuer with a private certificate authority. |
| **Single-use tokens** | On by default. Each token's `jti` can be exchanged once, so a token leaked from a log cannot be replayed. Turn it off only for issuers whose tokens have no `jti`, such as Google and Microsoft Entra. |
| **Max token lifetime** | The longest a token may be valid (`exp` minus `iat`), from 1 minute to 49 hours. Tokens that live longer are refused. Defaults to 1 hour. |

An organization can register up to 50 issuers.

### Public Issuers

Some issuers sign tokens for anyone who asks. Every GitHub repository can get a token from GitHub Actions, and every Google account can get one from Google. A rule on these issuers must include an **exact** match on a claim that identifies your own account, or anyone else's workload could match it.

| Issuer | A rule must exactly match one of |
| - | - |
| GitHub Actions | `sub`, `repository`, `repository_id`, `repository_owner`, `repository_owner_id`, `enterprise`, `enterprise_id` |
| Google | `sub`, `email` |
| GitLab.com | `sub`, `project_id`, `project_path`, `namespace_id`, `namespace_path` |
| HCP Terraform | `sub`, `terraform_organization_id`, `terraform_organization_name`, `terraform_workspace_id` |

Prefer numeric IDs such as `repository_owner_id`: a name can be claimed by someone else after a rename.

Issuers that belong to one account, such as an AWS account's STS issuer, a Microsoft Entra tenant or your own Kubernetes cluster, are not public. Their rules still need at least one condition.

## Federation Rules

| Field | Meaning |
| - | - |
| **Audience** | Must appear in the token's `aud` claim. Defaults to your deployment's URL. Request your workload's token for this audience. |
| **Conditions** | Up to 20 tests on token claims, all of which must pass. **Equals** is an exact match. **Matches** is a pattern where `*` stands for any run of characters, such as `repo:acme/etl:ref:refs/heads/*`. Array claims match when any element matches. |
| **Service account** | The member the issued token acts as. Agent-managed service accounts cannot be targets. |
| **Access** | **Read and write** (`api:read api:write`) or **Read only** (`api:read`). A workload can request a narrower scope, never a wider one. |
| **Token lifetime** | How long issued access tokens last, from 1 minute to 1 hour. A token never outlives the platform token it was exchanged from. |

The claims `iss`, `aud`, `exp`, `iat`, `nbf` and `jti` are checked on every exchange and cannot be conditions.

If more than one rule accepts a token, the exchange is refused until the workload names a rule with `federation_rule_id` (or `TEXTQL_FEDERATION_RULE_ID` in the CLI). An organization can have up to 200 rules.

Deleting an issuer deletes its rules. Deleting a service account deletes the rules that target it. Access tokens that were already issued stay valid until they expire, at most one hour later.

## Provider Setup

The wizard shows a ready-to-copy snippet for the rule you just created. The examples below show the same setup.

### GitHub Actions

Register `https://token.actions.githubusercontent.com` and match `repository_owner_id` exactly, plus `sub` for the repository and branch.

```yaml theme={null}
permissions:
  id-token: write
  contents: read

env:
  TEXTQL_BASE_URL: https://app.textql.com
  TEXTQL_ORG_ID: <org id>
  TEXTQL_FEDERATION_RULE_ID: <rule id>

steps:
  - run: refinery info
```

Inside GitHub Actions, the CLI requests the job's token itself when `TEXTQL_ORG_ID` is set.

### Google Cloud

Register `https://accounts.google.com` and match the service account's `email` exactly. Google's identity tokens have no `jti` claim, so turn off **Single-use tokens** for this issuer (the wizard does this for you). On GCE, GKE (with Workload Identity), Cloud Run and Cloud Functions, the CLI reads the token from the metadata server:

```bash theme={null}
export TEXTQL_ORG_ID=<org id>
export TEXTQL_FEDERATION_RULE_ID=<rule id>
export TEXTQL_FEDERATION_PROVIDER=gcp
refinery info
```

### AWS

Turn on outbound web identity federation under **IAM → Account settings** and register the token issuer URL it shows. Match `sub` on the role ARN.

Turning federation on does not let a role request tokens. Allow `sts:GetWebIdentityToken` in the workload role's IAM policy:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Action": "sts:GetWebIdentityToken", "Resource": "*" }
  ]
}
```

Then request a token for the rule's audience before running the CLI:

```bash theme={null}
aws sts get-web-identity-token \
  --audience https://app.textql.com \
  --signing-algorithm ES384 \
  --query WebIdentityToken --output text > /tmp/workload-token

export TEXTQL_ORG_ID=<org id>
export TEXTQL_FEDERATION_RULE_ID=<rule id>
export TEXTQL_FEDERATION_TOKEN_FILE=/tmp/workload-token
refinery info
```

On EKS, use the Kubernetes setup with the cluster's OIDC issuer instead.

### Kubernetes

Register the cluster's service account issuer (`kubectl get --raw /.well-known/openid-configuration` shows it). If its keys are not reachable from the internet, choose **Inline keys** and paste the output of `kubectl get --raw /openid/v1/jwks`. Match `sub` exactly, as `system:serviceaccount:<namespace>:<name>`.

Mount a projected token for the rule's audience and point the CLI at it:

```yaml theme={null}
spec:
  serviceAccountName: <name>
  containers:
    - name: app
      image: <your image>
      env:
        - { name: TEXTQL_BASE_URL, value: https://app.textql.com }
        - { name: TEXTQL_ORG_ID, value: <org id> }
        - { name: TEXTQL_FEDERATION_RULE_ID, value: <rule id> }
        - { name: TEXTQL_FEDERATION_TOKEN_FILE, value: /var/run/secrets/workload/token }
      volumeMounts:
        - name: workload-token
          mountPath: /var/run/secrets/workload
          readOnly: true
  volumes:
    - name: workload-token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              audience: https://app.textql.com
              expirationSeconds: 3600
```

### Microsoft Entra

Register your tenant's v2.0 issuer, `https://login.microsoftonline.com/<tenant id>/v2.0`, and match `sub` on the managed identity's object ID. Entra tokens have no `jti` claim, so turn off **Single-use tokens** for this issuer (the wizard does this for you). Write a token for your app registration's audience to a file and set `TEXTQL_FEDERATION_TOKEN_FILE`.

### Any Other OIDC Issuer

Choose **Custom OIDC**, register the issuer, and add conditions that only your workloads satisfy.

## Using the Token

### With the CLI

The [`refinery` CLI](https://docs.textql.com/core/admin/cli) uses workload identity when `TEXTQL_ORG_ID` is set together with a token source:

| Variable | Meaning |
| - | - |
| `TEXTQL_ORG_ID` | Organization to act in. Required. |
| `TEXTQL_FEDERATION_TOKEN_FILE` | File holding the workload's token, read on every command. |
| `TEXTQL_FEDERATION_PROVIDER` | `github` or `gcp`: where to request the token when there is no token file. GitHub Actions is detected automatically. |
| `TEXTQL_FEDERATION_AUDIENCE` | Audience to request the token for. Defaults to the deployment URL. |
| `TEXTQL_FEDERATION_RULE_ID` | Rule to use when more than one accepts the token. |

`TEXTQL_API_KEY` still takes precedence when set. With a token file, the CLI keeps the access token in its configuration directory until it expires, so later commands in the same job reuse it instead of exchanging the same single-use token again.

### Over HTTP

Exchange the token with a standard [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) request:

```bash theme={null}
curl -s https://app.textql.com/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
  --data-urlencode "subject_token=$(cat /tmp/workload-token)" \
  -d org_id=<org id> \
  -d federation_rule_id=<rule id>
```

```json theme={null}
{
  "access_token": "eyJ...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "api:read api:write"
}
```

Send the access token as `Authorization: Bearer <token>` on API requests. Add `scope=api:read` to the exchange to ask for read-only access.

## Testing a Token

On the **Workload Identity** tab, open a rule's menu and choose **Test token**, then paste a token your workload received. TextQL runs every check the exchange runs (issuer, signature, lifetime, audience, each condition, the service account, and whether the token was already used) and shows which pass, along with the token's decoded claims. Testing issues nothing and does not use up a single-use token.

## Troubleshooting

| Error | Cause |
| - | - |
| `unsupported_grant_type` | Token exchange is off for the organization. Turn it on under **Settings → Security**. |
| `invalid_grant`: subject token could not be verified | The issuer isn't registered in this organization, the signature doesn't verify, or the token expired. |
| `invalid_grant`: no federation rule accepts this token | The audience or a condition doesn't match. Use **Test token** to see which. |
| `invalid_grant`: more than one federation rule accepts this token | Pass `federation_rule_id`, or narrow the rules so only one matches. |
| `invalid_grant`: already exchanged | The issuer requires single-use tokens and this one was used. Get a fresh token from the platform. |
| `invalid_grant`: lifetime | The token lives longer than the issuer's maximum, or expires in under 30 seconds. |
| `invalid_scope` | The requested scope is wider than the rule grants. |
| `temporarily_unavailable` | The issuer's keys couldn't be fetched, or the replay check is down. Retry. |

## Audit Log

| Action | When |
| - | - |
| `auth.workload_issuer_registered`, `_updated`, `_deleted` | An issuer changes. Certificate bundles are recorded by their SHA-256, never in full. |
| `auth.federation_rule_created`, `_updated`, `_deleted` | A rule changes, with its audience, conditions, target and access. |
| `auth.workload_token_issued` | An exchange succeeds. The actor is the service account; the entry records the issuer, rule, token subject, access and lifetime. |
| `auth.workload_token_failed` | A token matched a rule but was refused (for example a replay, an ambiguous match or a lifetime problem). Tokens that match no rule are not recorded, so strangers' tokens from public issuers cannot fill the log. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.