# Workload identity for company service accounts

Let a customer company's CI jobs and cloud workloads act as its service accounts with their own short-lived tokens, so no API key is stored in CI.

A company that calls your app's [company API](/authentication/enterprise-company-api) from CI or from a cloud workload has, until now, stored a company API key there: a long-lived secret in a CI variable, which every maintainer of the pipeline can read and which works from anywhere until someone revokes it. Workload identity federation removes that secret. The CI job or cloud workload already holds a short-lived token its platform signs (GitHub Actions, GitLab, Google Cloud, AWS, Microsoft Entra or any other OpenID Connect issuer). It sends that token to your app, and gets a company API token of at most an hour, acting as one of the company's [service accounts](/authentication/enterprise-company-api#who-a-key-acts-as), only when the token matches a **workload credential** the company saved: its repository or account pinned by number, its branch or environment, its audience.

The exchange is an RFC 8693 token exchange, the shape [Google's security token service](https://docs.cloud.google.com/iam/docs/workload-identity-federation) and [OpenAI's workload identity federation](https://developers.openai.com/api/docs/guides/workload-identity-federation.md) use. The company sets it up in your app; your team turns it on once.

This builds on Enterprise SSO for the company: [set that up](/sign-in-setup/enterprise-sso) first, if you have not.

> **Note:**
  [Enterprise SSO](/authentication/enterprise-sso) set up for the company,
  through your own WorkOS account, and at least one service account on the
  company. Turning workload identity on needs a plan that includes enterprise
  SSO.


## How it fits together

| Piece                                    | Where it lives                                                                                       | Who sets it up                         |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------- |
| The switch                               | Console, **Authentication**, **Enterprise SSO**, the WorkOS provider card                            | You, once                              |
| The service account the workload acts as | The company's **API keys** screen in your app, or the enterprise's detail in your console            | The company's Keys admin, or your team |
| The workload credential and its pins     | Under the service account, **Workload credentials**                                                  | The company's Keys admin, or your team |
| The workflow step                        | The company's CI or cloud configuration: the card shows it with the credential's reference filled in | The company                            |
| The token                                | `POST /api/v1/company/token`, then the company API with `Authorization: Bearer`                      | The workload, each run                 |

```mermaid
sequenceDiagram
  participant W as CI job or workload
  participant P as Its platform (GitHub, GitLab, Google, AWS, Entra)
  participant B as Your app on BuildBase
  W->>P: Asks for an OIDC token for your app's audience
  P-->>W: A short-lived JWT signed by the platform
  W->>B: POST /api/v1/company/token with the JWT and the credential's reference
  B->>B: Checks the issuer, keys, claims, pins, single use, network and scopes
  B-->>W: A company API token of at most an hour, no refresh token
  W->>B: Company API calls with Authorization: Bearer
```

## 1. Turn it on

In the console, under **Authentication**, **Enterprise SSO**, the WorkOS provider card's **Workload identity for service accounts**. It is off by default. While it is off, each company's screen says your team has not turned it on, a credential cannot be saved (409 `workload_identity_off`), and the exchange answers `unsupported_grant_type`.

```bash
# Console route: an org API key with the enterprise config resource
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"enabled":true}' \
  "https://<your server>/api/auth/enterprise/config/workload-identity"
```

Turning it on needs a plan with enterprise SSO (402 otherwise). Turning it off is never refused, asks first in the console, and ends every workload token your companies hold at once: the answer says how many (`tokensRevoked`). The companies keep their credentials, and turning it back on revives no token.

## 2. The company adds a workload credential

A company's admin with **API keys** at **Can manage** adds one under a service account, in your app's [company settings](/authentication/enterprise-sso#company-api-keys-and-service-accounts) (the SDK's `EnterpriseWorkloadIdentity`, which `EnterpriseApiKeys` shows inside each service account), after confirming it's them. Your team can add one from the enterprise's detail in the console. A credential holds:

- **the preset**: GitHub Actions, GitLab, Google Cloud, AWS, Microsoft Entra, or another OpenID Connect issuer;
- **the issuer**, exact. A preset fixes it, or builds it from the account or tenant you give;
- **the audience**, exact: your app's issuer for the company's org by default, the same for every credential, shown on the card;
- **the pins**: a subject, exact or with one trailing `*`, and up to 10 claims, each matched exactly; every one must hold;
- **its scopes**, from the [company API's scopes](/authentication/enterprise-company-api#scopes). An admin in the app can give only scopes their own company role reaches; the exchange may ask for fewer, never more;
- **its token lifetime**: 5, 15, 30 or 60 minutes, 15 by default;
- **optional addresses**, as a key's. The company's IP allowlist applies too.

A service account holds at most 20 credentials, and a company at most 100. Names are unique per service account.

The card shows each credential's preset, issuer, pins, scopes, whether it is on, when it was last used and its last refusal with the claim that did not match, and, under **Setup**, its reference (`bbwi_<orgId>_<credentialId>`) and the step to put in the workflow.

### Pins are ids, never names

A shared issuer (GitHub's, GitLab.com's, Google's) signs tokens for every customer of that platform, so the issuer alone says nothing about whose workload it is. Each preset therefore requires pins on numbers that cannot be taken again: an organization or repository can be renamed, deleted and recreated by someone else with the same name, a number cannot. [Google](https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines) and [PyPI](https://docs.pypi.org/trusted-publishers/security-model/) give the same advice.

A credential is refused, with nothing saved, when:

| `detail`                                                                                       | Why                                                                                                                                              |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `matches_everything`                                                                           | No pin besides the issuer and audience, a subject of `*` alone, a prefix shorter than four characters, or a prefix with no exact claim beside it |
| `pin_required`                                                                                 | A preset's pin is missing, or a number pin is not a number (`claim` names it), for example GitHub's `repository_owner_id`                        |
| `wildcard_not_allowed`                                                                         | A `*` where the preset takes only an exact value (Google's and Entra's subjects)                                                                 |
| `use_preset`                                                                                   | A custom credential names GitHub's, GitLab.com's or Google's issuer: use its preset, so its pins apply                                           |
| `shared_issuer`                                                                                | Another issuer that signs for everyone (Microsoft's `common`, Apple, Buildkite and the other shared providers AWS lists)                         |
| `foreign_audience`                                                                             | The audience is another service's (`sts.amazonaws.com`, `api://AzureADTokenExchange`, npm's, PyPI's, OpenAI's, Anthropic's, Google IAM's)        |
| `bad_condition`                                                                                | A pin's claim or value is not one the preset takes, or more than 10 pins                                                                         |
| `not_https`, `discovery_unreachable`, `issuer_mismatch`, `jwks_unreachable`, `no_signing_keys` | The issuer's discovery document or keys could not be read, or the document names another issuer                                                  |
| `scope_beyond_you`                                                                             | A scope beyond the admin's own company role (`scopes` names them)                                                                                |

Each comes as 400 `workload_credential_invalid` with a sentence. **Check** on the form reads an issuer's discovery document and keys without saving anything.

## 3. Each preset

### GitHub Actions

Issuer `https://token.actions.githubusercontent.com` (or with your enterprise's slug after it, when [your enterprise customizes it](https://docs.github.com/en/enterprise-cloud@latest/actions/reference/security/oidc)). Required: `repository_owner_id`, `repository_id`, and one of `ref`, `environment` or `job_workflow_ref`. Find the numbers with `gh api repos/<owner>/<repo> --jq '.owner.id, .id'`.

Pin a protected branch, or an environment with required reviewers: a pull request from a fork runs without a protected `ref`, and an environment's reviewers decide who may deploy. See [GitHub's OIDC reference](https://docs.github.com/en/actions/reference/security/oidc) for every claim.

```yaml
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Company API token
        run: |
          ID_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=<audience>" | jq -r .value)
          COMPANY_API_TOKEN=$(curl -sS -X POST https://<your server>/api/v1/company/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=$ID_TOKEN" \
            -d credential=<reference> | jq -r .access_token)
          echo "::add-mask::$COMPANY_API_TOKEN"
          echo "COMPANY_API_TOKEN=$COMPANY_API_TOKEN" >> "$GITHUB_ENV"
```

The card shows this step with the reference, audience and address filled in.

### GitLab

Issuer `https://gitlab.com`. Required: `namespace_id`, `project_id`, and one of `ref` or `environment`; `ref_protected` is offered. The job asks for a token with [`id_tokens`](https://docs.gitlab.com/ci/secrets/id_token_authentication/), whose `aud` is the credential's audience.

### Google Cloud

Issuer `https://accounts.google.com`. Required: the service account's unique numeric ID as the subject, exact, and its `email`. The workload reads an ID token for the audience from the metadata server and sends it as `subject_token_type` `urn:ietf:params:oauth:token-type:id_token`. Google's ID tokens carry no `jti`, so one can be exchanged again until it expires; the card says so.

### AWS

The issuer is your AWS account's own (`https://<id>.tokens.sts.global.api.aws`), which IAM shows once [outbound identity federation](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetWebIdentityToken.html) is on. Required: the role's ARN as the subject, exact, or a prefix that covers `arn:aws:iam::<account>:role/`. AWS signs with ES384, which BuildBase accepts.

### Microsoft Entra

Give the tenant ID (a GUID); the issuer is built from it (`https://login.microsoftonline.com/<tenant>/v2.0`, or give the v1.0 `https://sts.windows.net/<tenant>/` in full), and the tenant is pinned (`tid`) for you. Required: the managed identity's or app's object ID as the subject, exact. A managed identity's token lives up to 24 hours, so this preset takes assertions that long. See [Microsoft's workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation).

### Another OpenID Connect issuer

Any https issuer whose discovery document names it exactly, with its keys at that document's `jwks_uri`: a company's own GitLab, a Kubernetes cluster's service account issuer, a SPIFFE server issuing JWT-SVIDs. It needs at least one pin besides the issuer and audience. A self-hosted app may trust an internal issuer over http. A GitHub Enterprise Server or a self-managed GitLab the internet cannot reach works only with a self-hosted app, since the issuer's keys must be read from where the app runs.

## 4. The exchange

```bash
curl -X POST https://<your server>/api/v1/company/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=$ID_TOKEN" \
  -d credential=bbwi_6512a0c0ffee0000c0ffee00_Ab12Cd34Ef56Gh78 \
  -d "scope=audit:read people:read"
```

Form-encoded as RFC 8693 says, or JSON with the same names. `subject_token_type` is `urn:ietf:params:oauth:token-type:jwt` or `urn:ietf:params:oauth:token-type:id_token`; `requested_token_type`, when sent, `urn:ietf:params:oauth:token-type:access_token`; `scope` is optional and may only narrow the credential's.

```json
{
  "access_token": "bbct_6512a0c0ffee0000c0ffee00_...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 600,
  "scope": "audit:read people:read"
}
```

Never a refresh token: exchange a fresh workload token when it runs out. The answer is `no-store`.

| Status | `error`                   | When                                                                                                                                                                                                                                                                                                                                                  |
| ------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request`         | A field missing or malformed, a token over 16 KB, a reference that does not parse                                                                                                                                                                                                                                                                     |
| 400    | `unsupported_grant_type`  | Another grant, or workload identity is off                                                                                                                                                                                                                                                                                                            |
| 400    | `invalid_grant`           | Every check on the token and the credential: "The token could not be exchanged."                                                                                                                                                                                                                                                                      |
| 400    | `invalid_grant`           | The same token a second time: "This token was already exchanged."                                                                                                                                                                                                                                                                                     |
| 400    | `invalid_scope`           | A scope beyond the credential's                                                                                                                                                                                                                                                                                                                       |
| 403    | `ip_not_allowed`          | The credential's addresses, or the company's IP allowlist                                                                                                                                                                                                                                                                                             |
| 403    | `tenant_restricted`       | The network's `allowed-company-ids` header names other companies of your app                                                                                                                                                                                                                                                                          |
| 429    |                           | More than 60 exchanges a minute for one credential, counting only tokens the credential accepts, or more than that many attempts a minute for one credential from one address. A stranger who copies the reference from a public workflow cannot use up your CI's minute |
| 503    | `temporarily_unavailable` | The token could not be checked for single use                                                                                                                                                                                                                                                                                                         |

`invalid_grant` says no more on purpose: the company's trail and the credential's card keep the reason (the claim that did not match, never its value).

### What is checked, in order

1. The request, then workload identity being on and the company's enterprise SSO.
2. The credential the reference names, on, its company active and its service account there.
3. The token's header: `typ` absent, `JWT` or `application/jwt` (never an ID-JAG or an access token), an asymmetric `alg`, a `kid`, and no key or key URL of its own.
4. The token's `iss` equal to the credential's issuer, before any key is read.
5. The key, from the issuer's discovery document's key set only, read again once a minute at most when the `kid` is new.
6. The signature, `aud` containing the credential's audience, `exp` and `iat` with 60 seconds of skew, and the token's life (`exp - iat`) at most an hour (a day for Entra).
7. The subject and every pin.
8. Single use: a token with a `jti` is exchanged once per app and issuer.
9. The addresses, the company's IP allowlist and its network header.
10. The scopes.

### Lifetimes and single use

A token lives the credential's minutes, and never more than twice what the workload's own token has left, nor under 60 seconds: a GitHub job token of five minutes gives a company API token of at most ten. GitHub's, GitLab's and AWS's tokens carry a `jti` and are exchanged once each; Google's and Entra's carry none and may be exchanged again until they expire, as their platforms hand the same cached token to every caller.

## 5. The SDK helper

In Node, with `@buildbase/sdk`, no React. The three helpers arrive in SDK 1.0.0; on 0.0.90 and earlier, call the token exchange yourself as section 4 shows.

```ts
import {
  companyApiWithWorkload,
  exchangeWorkloadToken,
  githubActionsIdToken,
} from '@buildbase/sdk';

// One exchange, in a GitHub Actions job with `id-token: write`.
const subjectToken = await githubActionsIdToken(
  process.env.BUILDBASE_AUDIENCE!
);
const { token, expiresAt, scopes } = await exchangeWorkloadToken({
  apiBase: 'https://<your server>/api/v1/company',
  credential: process.env.BUILDBASE_WORKLOAD_CREDENTIAL!,
  subjectToken,
});
```

`companyApiWithWorkload` keeps the token and exchanges a fresh one shortly before it expires, for a job that runs longer than one token. A refused exchange throws an error carrying the `error` above. In a React app, `EnterpriseWorkloadIdentity` and `useEnterpriseWorkloadIdentity` show and manage the credentials, and the API client's `workloadIdentity`, `createWorkloadCredential`, `updateWorkloadCredential`, `deleteWorkloadCredential` and `checkWorkloadIssuer` call the routes below.

## 6. Calling the company API with it

The token goes where a key goes: `Authorization: Bearer bbct_...`. It acts as the credential: the trail names the credential where it would name a key (`via: "api"`, `keyId` the credential's id, `keyOwner: "service_account"`), and it has the scopes it was given. `GET /api/v1/company/` describes it under `token` (its id, credential, service account, scopes, expiry and subject) where a key's call answers `key`. A refused call answers the [key codes](/authentication/enterprise-company-api#refusals) (`invalid_key`, `key_revoked`, `key_expired`, `ip_not_allowed`, `company_unavailable`, `scope_required`) with sentences that say "token". No scope manages keys, service accounts or credentials, so a workload token never makes another.

## How access ends

| What happens                                  | What ends, and how fast                                                                                          |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| The token expires                             | 401 `key_expired`                                                                                                |
| The credential is turned off                  | At once: its live tokens 401 `key_revoked`, its next exchange refused. Turned back on, the old tokens stay ended |
| The credential is deleted                     | At once, the same                                                                                                |
| The service account is deleted                | At once: its credentials go, their tokens end, and its keys are revoked as before                                |
| You turn workload identity off                | At once: every workload token of every company; the exchange answers `unsupported_grant_type`                    |
| The company's SSO is off or it is not active  | On the next call: 403 `company_unavailable`                                                                      |
| The addresses, IP allowlist or network header | At the exchange and on every call: 403                                                                           |
| The CI platform stops issuing (the job ends)  | No new exchange; the live token runs to its end                                                                  |
| A credential is narrowed                      | Live tokens keep their scopes until they expire, at most an hour; **End live tokens now** ends them              |

## The trail, webhooks and insights

| Entry                                                                                                                        | Written when                                                                                                                          |
| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `enterprise_workload_credential_created`, `enterprise_workload_credential_updated`, `enterprise_workload_credential_deleted` | Every change, with the preset, issuer, pins, scopes, `change` and the live tokens it ended (`tokensRevoked`)                          |
| `enterprise_workload_token_issued`                                                                                           | Every token, with the run's claims (repository, ref, run id), so a change made with it traces to the run                              |
| `enterprise_workload_token_refused`                                                                                          | A refusal, at most once an hour per credential and reason, with the claim that did not match. An unknown reference is a log line only |

Two webhook events and workflow triggers: `enterprise.workload_credential_updated` (every change, with `change`, `status` and `tokensRevoked`) and `enterprise.workload_access` (`outcome` `issued`, at most once an hour per credential and subject, or `refused`, at most once an hour per credential and reason). With [company audit logs](/authentication/enterprise-sso#company-audit-logs-in-workos) on, the five entries reach WorkOS as `company.workload_credential_created`, `company.workload_credential_updated`, `company.workload_credential_deleted`, `company.workload_token_issued` and `company.workload_token_refused`. The company's insights count the tokens issued, the exchanges refused by reason and the credentials on (`workloads`).

## Routes

| Surface | Read                                                                                         | Change                                                                                                                                                |
| ------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| App     | `GET /api/v1/public/workspaces/:workspaceId/enterprise/workload-identity` (Keys at Can view) | `POST .../workload-identity/credentials`, `PATCH` and `DELETE .../credentials/:credentialId`, `POST .../workload-identity/check` (Keys at Can manage) |
| Console | `GET /api/auth/enterprise/enterprises/:id/workload-identity`                                 | The same four under `/:id/workload-identity`                                                                                                          |
| Switch  | `GET /api/auth/enterprise/config/workload-identity`                                          | `PUT /api/auth/enterprise/config/workload-identity` `{ "enabled": true }`                                                                             |

In the app, adding a credential, turning one back on and any change that widens it ask the admin to [confirm it's them](/authentication/enterprise-sso#confirming-it-s-you); turning one off, deleting it and narrowing it never ask. An agent's session is refused (`agent_session_refused`). `PATCH` takes `endLiveTokens: true` to end live tokens after a narrowing change.

## Not supported yet

- Uploaded key sets and typed key URLs; expressions and nested claims; X.509 or mTLS exchange.
- Presets for Buildkite, HCP Terraform, CircleCI, Bitbucket and Vercel: their shared issuers are refused until a preset pins their tenancy.
- Managing credentials through the company API.
- Proof of possession for workload tokens, and more than one audience per credential.

These pages were checked against stubs of the five platforms' issuers; a live run on GitHub Actions is still to come.

## Next steps

- [Company API](/authentication/enterprise-company-api) - what a token can call
- [Enterprise SSO for IT admins](/authentication/enterprise-sso-it-admin#workload-identity-for-ci-and-cloud) - the page to send the company's IT admin
- [Error codes](/reference/error-codes) - every code and reason
