Configure Google Cloud Workload Identity Federation for External CI
This is for developers wiring an external CI system into Google Cloud without storing service account keys. You will create a workload identity pool and OIDC provider, bind a service account, and verify that your CI job can exchange its OIDC token for short-lived Google credentials.
TL;DR — You can let an external CI system access Google Cloud without a JSON key by exchanging the CI job's OIDC token through a Workload Identity Pool and impersonating a service account. The most common failure is a bad attribute mapping or audience mismatch: map the CI token claims correctly and set the provider audience exactly to the value your CI actually sends. Reading time: ~5 min
Goal
When you finish, a job running in your external CI system will obtain short-lived Google Cloud credentials via Workload Identity Federation and successfully call Google Cloud APIs as a specific service account, with no service account key stored in the CI platform.
Prerequisites
- A Google Cloud project and permission to administer IAM: one of
roles/owner, or enough to create pools/providers/service accounts and IAM bindings (roles/iam.workloadIdentityPoolAdmin,roles/iam.serviceAccountAdmin,roles/iam.serviceAccountTokenCreator,roles/resourcemanager.projectIamAdmin) — check withgcloud projects get-iam-policy PROJECT_ID gcloudCLI installed, current enough to support workload identity federation — check withgcloud --versionjqinstalled — check withjq --version- An external CI system that can issue an OIDC ID token to jobs; you need the issuer URL, the audience value it sends, and at least one stable claim you can bind on, such as repository, project path, branch, or subject
- A shell with
bash,curl, andbase64 - These values decided up front:
| Variable | Example |
|---|---|
PROJECT_ID | acme-prod |
PROJECT_NUMBER | 123456789012 |
POOL_ID | ci-pool |
PROVIDER_ID | external-ci |
SA_NAME | ci-deployer |
ISSUER_URI | https://token.actions.githubusercontent.com |
AUDIENCE | https://github.com/acme |
| claim to bind | repository or sub |
| claim value to allow | acme/api |
Steps
Step 1: Set variables and confirm project context
export PROJECT_ID="acme-prod"
export PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
export POOL_ID="ci-pool"
export PROVIDER_ID="external-ci"
export SA_NAME="ci-deployer"
export SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
export ISSUER_URI="https://token.actions.githubusercontent.com"
export AUDIENCE="https://github.com/acme"
export ALLOWED_REPOSITORY="acme/api"
gcloud config set project "$PROJECT_ID"
printf 'PROJECT_ID=%s\nPROJECT_NUMBER=%s\n' "$PROJECT_ID" "$PROJECT_NUMBER"
You should see Updated property [core/project]. and your numeric PROJECT_NUMBER printed.
Step 2: Create the service account the CI job will impersonate
gcloud iam service-accounts create "$SA_NAME" \
--display-name="CI deployer"
gcloud iam service-accounts describe "$SA_EMAIL" \
--format='value(email)'
You should see the service account email, for example ci-deployer@acme-prod.iam.gserviceaccount.com.
Step 3: Create the workload identity pool
gcloud iam workload-identity-pools create "$POOL_ID" \
--location="global" \
--display-name="External CI pool"
gcloud iam workload-identity-pools describe "$POOL_ID" \
--location="global" \
--format='value(name)'
You should see a resource name like projects/123456789012/locations/global/workloadIdentityPools/ci-pool.
Step 4: Create the OIDC provider with explicit claim mapping
Use the claim names your CI actually emits. The example below is for a token with sub, repository, repository_owner, and aud claims.
gcloud iam workload-identity-pools providers create-oidc "$PROVIDER_ID" \
--location="global" \
--workload-identity-pool="$POOL_ID" \
--display-name="External CI OIDC" \
--issuer-uri="$ISSUER_URI" \
--allowed-audiences="$AUDIENCE" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner,attribute.aud=assertion.aud"
gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \
--location="global" \
--workload-identity-pool="$POOL_ID" \
--format='json(issuerUri,attributeMapping,oidc.allowedAudiences)'
You should see your issuer URI, the attribute mapping, and the allowed audience in the JSON output.
Step 5: Allow identities from the provider to impersonate the service account
This binding restricts impersonation to jobs whose token claim repository equals acme/api.
gcloud iam service-accounts add-iam-policy-binding "$SA_EMAIL" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/attribute.repository/${ALLOWED_REPOSITORY}"
gcloud iam service-accounts get-iam-policy "$SA_EMAIL" \
--format='json(bindings)'
You should see a binding for roles/iam.workloadIdentityUser with a principalSet://.../attribute.repository/acme/api member.
Step 6: Grant the service account the Google Cloud role it needs
Replace the role with the minimum your job requires. Example: read objects from one bucket.
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${SA_EMAIL}" \
--role="roles/storage.objectViewer"
You should see Updated IAM policy for project and no error.
Step 7: In your CI job, fetch the OIDC token and write the external account credential file
The exact environment variables differ by CI vendor. The pattern is always the same: get the job OIDC token, save it to a file, and point Google auth at a generated external account JSON.
cat > /tmp/gcp-wif-cred.json <<EOF
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"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/ci-oidc-token"
}
}
EOF
export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-wif-cred.json
You should have /tmp/gcp-wif-cred.json present in the job workspace.
Step 8: Exchange the token and call Google Cloud
If your CI exposes the OIDC token directly, write it to /tmp/ci-oidc-token. If it exposes a URL to mint one, call that URL with the required audience and write the returned token. Then test with gcloud auth login --cred-file.
# Replace this line with your CI vendor's documented way to obtain the job OIDC token.
printf '%s' "$CI_JOB_OIDC_TOKEN" > /tmp/ci-oidc-token
jq -R 'split(".") | .[1] | @base64d | fromjson' < /tmp/ci-oidc-token
gcloud auth login --cred-file="$GOOGLE_APPLICATION_CREDENTIALS" --brief
gcloud auth list
gcloud storage ls gs://YOUR_BUCKET_NAME --project="$PROJECT_ID"
You should see the decoded token claims from jq, then You are now logged in as [principal://iam.googleapis.com/...] or similar, and the bucket listing should return exit code 0.
Verify it works
Run these checks inside the CI job after writing the token file.
set -euo pipefail
jq -R 'split(".") | .[1] | @base64d | fromjson | {iss,aud,sub,repository,repository_owner}' < /tmp/ci-oidc-token
gcloud auth login --cred-file="$GOOGLE_APPLICATION_CREDENTIALS" --brief
gcloud auth print-access-token >/dev/null
gcloud projects get-iam-policy "$PROJECT_ID" --flatten="bindings[].members" --filter="bindings.members:serviceAccount:${SA_EMAIL}" --format='table(bindings.role)'
Expected results:
- The decoded token shows the exact
issandaudyou configured. gcloud auth print-access-tokenexits0and prints a token if you remove>/dev/null.- Your real API call succeeds, for example
gcloud storage lsorgcloud run services list, depending on the role you granted.
If the audience is wrong, a typical failure looks like:
ERROR: (gcloud.auth.login) There was a problem refreshing your current auth tokens: ('Error code invalid_target: The target service indicated by the "audience" parameters is invalid.', '{"error":"invalid_target","error_description":"The target service indicated by the \"audience\" parameters is invalid."}')
If the principal binding does not match the token claims, a typical failure looks like:
ERROR: (gcloud.auth.login) There was a problem refreshing your current auth tokens: ('Unable to acquire impersonated credentials', '{"error":{"code":403,"message":"Permission iam.serviceAccounts.getAccessToken denied on resource...","status":"PERMISSION_DENIED"}}')
Common pitfalls
Audience mismatch
Mistake: you set --allowed-audiences to one value, but your CI token's aud claim is different.
Symptom: invalid_target during token exchange, or The target service indicated by the "audience" parameters is invalid.
Fix: decode the token with jq -R 'split(".") | .[1] | @base64d | fromjson' and set --allowed-audiences to that exact aud value, then recreate or update the provider.
Wrong claim mapping or wrong claim name
Mistake: you bind on attribute.repository, but your CI token does not contain repository, or the claim name is different.
Symptom: STS exchange succeeds, but service account impersonation fails with 403 PERMISSION_DENIED on iam.serviceAccounts.getAccessToken.
Fix: decode the token, inspect the actual claims, and change --attribute-mapping plus the principalSet://.../attribute.NAME/VALUE binding to match those exact claim names and values.
Using the project ID where Google expects the project number
Mistake: you build the principalSet:// member string with PROJECT_ID instead of numeric PROJECT_NUMBER.
Symptom: IAM binding is added to the wrong principal string and never matches; impersonation fails with 403.
Fix: rebuild the member string with projects/${PROJECT_NUMBER}/locations/global/... and re-run add-iam-policy-binding.
Binding too broadly
Mistake: you grant principalSet://.../* or bind only on google.subject when the subject is not stable enough across repos/branches.
Symptom: unrelated CI jobs can impersonate the service account.
Fix: bind on a stable, restrictive attribute such as repository or project path, and use one service account per deployment boundary.
Token file contains a newline or the wrong token type
Mistake: the CI step writes a bearer access token or a JSON blob instead of the raw OIDC ID token string, or appends a trailing newline in a way your tooling mishandles.
Symptom: invalid_grant, invalid_request, or JWT decode failures from jq.
Fix: write the raw ID token only, with printf '%s' "$CI_JOB_OIDC_TOKEN" > /tmp/ci-oidc-token, then verify it decodes into three JWT segments.
Granting the service account no actual Google Cloud permissions
Mistake: federation and impersonation work, but the service account has no role on the target project/resource.
Symptom: gcloud auth print-access-token works, but the real API call returns 403 such as storage.objects.list denied.
Fix: grant the service account the minimum required role on the target resource, for example roles/storage.objectViewer on the bucket or project.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI