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 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, 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 and OpenAI's workload identity federation 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 first, if you have not.
Before you start
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 |
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: Bearer1. 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.
# 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 (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. 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 and PyPI 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). 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 for every claim.
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, 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 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.
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
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.
{
"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
- The request, then workload identity being on and the company's enterprise SSO.
- The credential the reference names, on, its company active and its service account there.
- The token's header:
typabsent,JWTorapplication/jwt(never an ID-JAG or an access token), an asymmetricalg, akid, and no key or key URL of its own. - The token's
issequal to the credential's issuer, before any key is read. - The key, from the issuer's discovery document's key set only, read again once a minute at most when the
kidis new. - The signature,
audcontaining the credential's audience,expandiatwith 60 seconds of skew, and the token's life (exp - iat) at most an hour (a day for Entra). - The subject and every pin.
- Single use: a token with a
jtiis exchanged once per app and issuer. - The addresses, the company's IP allowlist and its network header.
- 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.
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 (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 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; 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 - what a token can call
- Enterprise SSO for IT admins - the page to send the company's IT admin
- Error codes - every code and reason