Skip to content
BuildBase

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

PieceWhere it livesWho sets it up
The switchConsole, Authentication, Enterprise SSO, the WorkOS provider cardYou, once
The service account the workload acts asThe company's API keys screen in your app, or the enterprise's detail in your consoleThe company's Keys admin, or your team
The workload credential and its pinsUnder the service account, Workload credentialsThe company's Keys admin, or your team
The workflow stepThe company's CI or cloud configuration: the card shows it with the credential's reference filled inThe company
The tokenPOST /api/v1/company/token, then the company API with Authorization: BearerThe 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: 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.

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

detailWhy
matches_everythingNo 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_requiredA 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_allowedA * where the preset takes only an exact value (Google's and Entra's subjects)
use_presetA custom credential names GitHub's, GitLab.com's or Google's issuer: use its preset, so its pins apply
shared_issuerAnother issuer that signs for everyone (Microsoft's common, Apple, Buildkite and the other shared providers AWS lists)
foreign_audienceThe audience is another service's (sts.amazonaws.com, api://AzureADTokenExchange, npm's, PyPI's, OpenAI's, Anthropic's, Google IAM's)
bad_conditionA 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_keysThe issuer's discovery document or keys could not be read, or the document names another issuer
scope_beyond_youA 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.

StatuserrorWhen
400invalid_requestA field missing or malformed, a token over 16 KB, a reference that does not parse
400unsupported_grant_typeAnother grant, or workload identity is off
400invalid_grantEvery check on the token and the credential: "The token could not be exchanged."
400invalid_grantThe same token a second time: "This token was already exchanged."
400invalid_scopeA scope beyond the credential's
403ip_not_allowedThe credential's addresses, or the company's IP allowlist
403tenant_restrictedThe network's allowed-company-ids header names other companies of your app
429More 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
503temporarily_unavailableThe 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.

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 happensWhat ends, and how fast
The token expires401 key_expired
The credential is turned offAt once: its live tokens 401 key_revoked, its next exchange refused. Turned back on, the old tokens stay ended
The credential is deletedAt once, the same
The service account is deletedAt once: its credentials go, their tokens end, and its keys are revoked as before
You turn workload identity offAt once: every workload token of every company; the exchange answers unsupported_grant_type
The company's SSO is off or it is not activeOn the next call: 403 company_unavailable
The addresses, IP allowlist or network headerAt 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 narrowedLive tokens keep their scopes until they expire, at most an hour; End live tokens now ends them

The trail, webhooks and insights

EntryWritten when
enterprise_workload_credential_created, enterprise_workload_credential_updated, enterprise_workload_credential_deletedEvery change, with the preset, issuer, pins, scopes, change and the live tokens it ended (tokensRevoked)
enterprise_workload_token_issuedEvery token, with the run's claims (repository, ref, run id), so a change made with it traces to the run
enterprise_workload_token_refusedA 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

SurfaceReadChange
AppGET /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)
ConsoleGET /api/auth/enterprise/enterprises/:id/workload-identityThe same four under /:id/workload-identity
SwitchGET /api/auth/enterprise/config/workload-identityPUT /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