Skip to content

Google Workload Identity

Cuttlefish is an OIDC issuer. A job asks it for a short-lived, signed token that says which repository, ref, Cuttlefish project and trust tier it runs for, and Google exchanges that token for service-account credentials. No key is stored anywhere.

Overview

  1. Cuttlefish: give the node that authenticates permissions.idToken: write (native workflows), or id-token: write (GitHub Actions jobs running live on Cuttlefish).
  2. GCP: add an OIDC provider for https://api.cuttlefish.sh to your Workload Identity pool, with a condition that pins the repository and the Cuttlefish project.
  3. Bind service accounts to the principal sets that fit each job: a plan account any run may use, and a deploy account only trusted runs of main may use.
  4. Run the job. pipeline/gcp-auth 0.3.2 or google-github-actions/auth does the exchange.
Project write access is deploy access
Native workflows live in the Cuttlefish project, not in the repository. Anyone who can publish a workflow in a project can run code with that project's tokens, so the provider condition must pin the project as well as the repository, and anything that deploys must require trust_tier == 'trusted'.

Two ways a job gets a token

Request protocol (use this)Legacy OIDC_TOKEN
Enabled bypermissions.idToken: write on the node or workflowbeing a pipeline/gcp-auth node
Delivered asACTIONS_ID_TOKEN_REQUEST_URL and _TOKEN, GitHub's protocolan OIDC_TOKEN secret minted when the node is leased
Runs that get itevery run except fork pull requests and runs whose GitHub context cannot be readtrusted runs; manual, API or scheduled runs with no GitHub context
Same-repository pull requestsyes, trust_tier untrustedno
ClaimsGitHub's claim set plus project_id, trust_tier, workflow_version_id, workflow_digestsub pipeline:<project>:<run>:<node>, project_id, run_id, node_id
Audiencewhatever the job requestsalways https://api.cuttlefish.sh
Lifetime5 minutes10 minutes

pipeline/gcp-auth 0.3.2 uses the grant whenever its node has one and falls back to OIDC_TOKEN. A trusted run is leased both; the grant wins because only its token carries the repository, ref and tier a provider condition should pin. Pushes to main and pull requests then authenticate the same way, and the provider tells them apart by trust_tier, event_name and ref.

The flow

Request protocol: from webhook to a service-account tokenGitHub notifies the control plane, which leases the authenticating node an id-token grant. The node requests a token, writes credentials into the workspace, and the next node exchanges the token at Google STS for a service-account access token.GitHubControl planeapi.cuttlefish.shRunneryour riggcp-auth nodemicro-VMNext nodeterraform, deployGoogleSTS + IAM1. webhook: push or pull_request2.classify trust_tier,record GitHub context3. poll for work4. lease + id-token grant(idToken: write nodes, never forks)5. start; grant in /run/secrets6. GET /_apis/oidc/token?audience=…Bearer <request token>7.attempt still running here?mint RS256 id-token (5 min)8. {"value": "<id-token>"}9.write .gcp-credentials/(credentials.json, oidc-token)10. start in the same workspace11. STS token exchange(subject token = id-token)12. fetch discovery + JWKS13.verify iss, aud, signature;mapping + condition;binding for the SA?14. service-account access token (1 h)
Request protocol: from webhook to a service-account token
Steps as text
  1. GitHub → Control plane: webhook: push or pull_request
  2. Control plane: classify trust_tier, record GitHub context
  3. Runner → Control plane: poll for work
  4. Control plane → Runner: lease + id-token grant (idToken: write nodes, never forks)
  5. Runner → gcp-auth node: start; grant in /run/secrets
  6. gcp-auth node → Control plane: GET /_apis/oidc/token?audience=… Bearer <request token>
  7. Control plane: attempt still running here? mint RS256 id-token (5 min)
  8. Control plane → gcp-auth node: {"value": "<id-token>"}
  9. gcp-auth node: write .gcp-credentials/ (credentials.json, oidc-token)
  10. Runner → Next node: start in the same workspace
  11. Next node → Google: STS token exchange (subject token = id-token)
  12. Google → Control plane: fetch discovery + JWKS
  13. Google: verify iss, aud, signature; mapping + condition; binding for the SA?
  14. Google → Next node: service-account access token (1 h)

The request token is a capability for one attempt: the control plane mints an id-token only while that attempt is running on the runner that leased it, and the runner redacts it from logs. Google fetches the issuer's discovery document and keys from https://api.cuttlefish.sh/.well-known/openid-configuration to verify the signature.

Legacy OIDC_TOKEN, used only by nodes without a grantThe control plane injects an OIDC_TOKEN secret into a pipeline/gcp-auth node of a trusted, manual, API or scheduled run; the node writes it as the credential's token file.Control planeRunnergcp-auth nodeGoogle STS1. poll for work2.gcp-auth node, and trusted(or manual, API, cron run)?3. lease + secret OIDC_TOKEN(aud api.cuttlefish.sh, 10 min)4. start; OIDC_TOKEN in /run/secrets5.write .gcp-credentials/oidc-tokenthe exchange is the same as in the request flow
Legacy OIDC_TOKEN, used only by nodes without a grant
Steps as text
  1. Runner → Control plane: poll for work
  2. Control plane: gcp-auth node, and trusted (or manual, API, cron run)?
  3. Control plane → Runner: lease + secret OIDC_TOKEN (aud api.cuttlefish.sh, 10 min)
  4. Runner → gcp-auth node: start; OIDC_TOKEN in /run/secrets
  5. gcp-auth node: write .gcp-credentials/oidc-token

Who gets a token

CaseRequest protocolLegacy OIDC_TOKEN
Push to the default or a protected branch, merge queueyes (trusted)yes
Pull request from the same repositoryyes (untrusted)no
Pull request from a forknevernever
Manual, API or scheduled run with no GitHub contextyes (untrusted, no repository claims)yes
GitHub Actions job in shadow modenevernever
Rig that shares a Docker daemon with jobswithheld by the runnerinjected; avoid such rigs
Sibling node without the permissionnono (it reads what gcp-auth wrote)

Grants are kept only on micropod's Connect transport, where every attempt is its own micro-VM. The docker, podman and machine-over-docker executors, and micropod's shim transport, withhold them.

Configure Cuttlefish

Native workflow with pipeline/gcp-auth

workflows/<name>.yaml

spec:
  nodes:
    - id: gcp-auth
      taskRef:
        name: pipeline/gcp-auth
        version: "0.3.2"
      permissions:
        idToken: write          # this node only; siblings get nothing
      inputs:
        method:
          value: wif
        workloadIdentityProvider:
          secretRef: { name: WORKLOAD_IDENTITY_PROVIDER }
        serviceAccount:
          secretRef: { name: SERVICE_ACCOUNT_EMAIL }
        # audience defaults to the issuer's origin, https://api.cuttlefish.sh
    - id: terraform-plan
      taskRef:
        name: pipeline/terraform
        version: "0.3.0"        # sources .gcp-credentials/env.sh itself
InputMeaning
methodwif (default) or service-account-key
workloadIdentityProviderprojects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>, or the WORKLOAD_IDENTITY_PROVIDER secret
serviceAccountthe account to impersonate, or the SERVICE_ACCOUNT_EMAIL secret
audienceaudience of a request-protocol token; default the issuer's origin. Must be in the provider's allowedAudiences

gcp-auth writes .gcp-credentials/ into the run's workspace: credentials.json (an external_account config reading the token file), oidc-token and env.sh. Later nodes source env.sh; pipeline/terraform and pipeline/docker-build do so themselves.

GitHub Actions jobs running live on Cuttlefish

.github/workflows/<name>.yml

permissions:
  contents: read
  id-token: write
jobs:
  plan:
    runs-on: [self-hosted, cuttlefish, linux]
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
          # omit when the provider leaves allowedAudiences unset
          audience: https://api.cuttlefish.sh

A live job whose permissions grant id-token: write gets the same grant, so google-github-actions/auth works unchanged. Shadow-mode jobs never get one.

Any other node

An inline node that authenticates itself

- id: deploy
  image: google/cloud-sdk:slim
  permissions: { idToken: write }
  script: |
    curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
      "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://api.cuttlefish.sh" \
      | jq -r .value > /tmp/oidc
    cat > /tmp/creds.json <<EOF
    {"type": "external_account",
     "audience": "//iam.googleapis.com/${WIF_PROVIDER}",
     "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
     "token_url": "https://sts.googleapis.com/v1/token",
     "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SA_EMAIL}:generateAccessToken",
     "credential_source": {"file": "/tmp/oidc"}}
    EOF
    gcloud auth login --cred-file=/tmp/creds.json

Configure GCP

1. The provider

Create the Cuttlefish provider

PROJECT_ID=<gcp-project>; POOL_ID=<pool>
PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')
REPO=<owner>/<repo>; REPO_ID=$(gh api "repos/$REPO" --jq .id)
CF_PROJECT=<cuttlefish project id>

gcloud iam workload-identity-pools providers create-oidc cuttlefish \
  --project="$PROJECT_ID" --location=global --workload-identity-pool="$POOL_ID" \
  --issuer-uri="https://api.cuttlefish.sh" \
  --allowed-audiences="https://api.cuttlefish.sh" \
  --attribute-mapping="google.subject=assertion.sub,\
attribute.repository=assertion.repository,\
attribute.project_id=assertion.project_id,\
attribute.trust_tier=assertion.trust_tier,\
attribute.event_name=assertion.event_name,\
attribute.ref=assertion.ref,\
attribute.deploy=assertion.repository + ':' + assertion.project_id + ':' + assertion.trust_tier + ':' + assertion.ref,\
attribute.plan=assertion.repository + ':' + assertion.project_id" \
  --attribute-condition="assertion.repository == '${REPO}' \
&& assertion.repository_id == '${REPO_ID}' \
&& assertion.project_id == '${CF_PROJECT}'"
SettingValueWhy
issuer-urihttps://api.cuttlefish.sh, no trailing slashGoogle compares iss byte for byte and fetches the keys from it
allowed-audienceshttps://api.cuttlefish.shgcp-auth's default audience; leave unset only if every job requests the provider's resource URL
attribute.deployrepository:project:trust_tier:reflets a deploy account bind to trusted runs of main only
attribute.planrepository:projectlets a plan-only account accept pull requests
conditionrepository, repository_id, project_idrefuses other repositories and other Cuttlefish projects mapped to this one

The condition leaves room for pull requests; the bindings decide what each tier may impersonate. A pool that only deploys can require trust_tier == 'trusted' and ref == 'refs/heads/main' in the condition instead. Never copy a GitHub provider's condition: it does not pin the Cuttlefish project.

2. Bindings: split plan from apply

Bind each account to the principal set it may serve

POOL="projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}"

# Plan: any run of the repository in this Cuttlefish project, pull requests included.
gcloud iam service-accounts add-iam-policy-binding "terraform-plan@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/${POOL}/attribute.plan/${REPO}:${CF_PROJECT}"

# Apply / deploy: only trusted runs of main.
gcloud iam service-accounts add-iam-policy-binding "terraform-apply@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/${POOL}/attribute.deploy/${REPO}:${CF_PROJECT}:trusted:refs/heads/main"

Give the plan account read-only roles. A pull request is untrusted by definition, so it must never be able to impersonate an account that changes infrastructure.

Bindings attach to the pool, not the provider
An existing attribute.repository/<owner>/<repo> grant made for a GitHub provider also matches Cuttlefish tokens for that repository, from any project and tier. Audit each account's policy with gcloud iam service-accounts get-iam-policy before adding the provider.

3. Point the workflow at it

Set the project secrets the workflow reads: WORKLOAD_IDENTITY_PROVIDER to the provider's resource name and SERVICE_ACCOUNT_EMAIL. Plan and apply jobs name different accounts.

Prove it

From a node with permissions.idToken: write

AUD_PROVIDER="//iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/cuttlefish"
TOKEN=$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
  "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://api.cuttlefish.sh" | jq -r .value)

# What the provider will see (no signature check here):
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
  | jq '{iss, aud, sub, repository, project_id, trust_tier, event_name, ref}'

curl -fsS https://sts.googleapis.com/v1/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d requested_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
  -d scope=https://www.googleapis.com/auth/cloud-platform \
  -d audience="$AUD_PROVIDER" -d subject_token="$TOKEN" | jq 'has("access_token")'

true means the provider accepts the issuer, audience and condition. Then prove the refusals: a pull request must get a 403 from generateAccessToken on the deploy account, and a workflow in another Cuttlefish project mapped to the same repository must fail the exchange.

Troubleshooting

SymptomCauseFix
ERROR: no OIDC tokengcp-auth got neither a grant nor OIDC_TOKENadd permissions.idToken: write to the gcp-auth node (fork pull requests never get one)
ERROR: OIDC_TOKEN is not setgcp-auth 0.3.1 or older on an untrusted runpin pipeline/gcp-auth 0.3.2 and grant idToken: write
permissions.idToken is not available on this rigthe rig shares a Docker daemon with jobsrun on a micropod (Connect) rig
STS: audience does not matchthe token's aud is not in allowedAudiencesset the audience input to an allowed value
STS: rejected by the attribute conditionrepository, project or tier does not matchdecode the token (Prove it) and compare with the condition
STS: attribute mapping failedthe mapping reads a claim the token lacks (legacy token, or run_number)use the request protocol; map only claims Cuttlefish emits
iam.serviceAccounts.getAccessToken deniedno workloadIdentityUser binding matches the principalbind the right principal set (plan or deploy)
the next node fails minutes laterthe 5-minute id-token in the credentials file expired before the exchangekeep auth directly before the node that uses it, or authenticate in that node
401 from /_apis/oidc/tokenthe request token's attempt is no longer running on this runnerrequest the token within the attempt that holds the grant

Google's client libraries exchange once and cache the result for an hour. A job that runs longer than an hour after authenticating cannot refresh from the expired token file, so authenticate in the node that runs long.