CoursesGCP securityWorkload Identity Federation

Workload Identity Federation

Keyless CI and cross-cloud access, no static keys.

Advanced35 min · lesson 13 of 15

Software needs to log in too. When a build job or a script talks to Google Cloud, it doesn't type a username and password the way you would. It uses a service account, which is just a login built for a machine instead of a person. And the old-fashioned way to hand a machine that login is a service-account key: a small JSON file with a private key sitting inside it. Download one and you're holding a credential that works forever, from any computer on earth, until a human remembers to rotate it. Nobody remembers. Those files drift into .env files, into CI variables, into a Slack thread someone pasted for a teammate, and now and then into a public GitHub repository by accident. GitHub's secret scanner flags thousands of leaked Google Cloud keys a year. Tidier key hygiene won't save you. The only real fix is to hold no key at all.

Think about how a good office building handles visitors. You don't get a permanent badge mailed to your house weeks ahead. You walk up to the front desk, show the ID you already carry, a driver's license or a passport, and the desk prints a paper pass that's good for today and nothing more. Lose it in the car park and by tomorrow it's a worthless slip. Workload Identity Federation is that front desk, built for Google Cloud. Your build job, or a workload running over in Amazon's cloud, already carries an identity that its own platform vouches for. Federation checks that identity at the desk and hands back a Google credential that expires in about an hour. You never take a permanent key home.

So what is the ID your workload already carries? It's called an OIDC token. OIDC stands for OpenID Connect, and it's nothing fancier than an agreed format for a platform to sign a short statement about who's asking. The statement says something like "this is really run number 12345, from the repository acme/api, on branch main," and it's signed with a key that only that platform holds. GitHub Actions mints a fresh one for every workflow run. GitLab, Amazon, and any other OpenID Connect issuer do the same. Google can check that signature against the platform's published public keys without ever storing a password, and without your workload ever holding a signing key of its own.

Stand up the pool and the provider

Two objects do the work here. The first is a workload identity pool, which you can picture as the guest register for identities that come from outside Google Cloud. You create it once and reuse it. The second is a provider, a single entry in that register for one source of visitors, GitHub in this case. The provider is where you decide which details on the incoming ID you're actually willing to trust. You set up both, and then you never hand out a key again.

pool + oidc provider
# The pool holds external identities; usually one per project is plenty.
gcloud iam workload-identity-pools create ci-pool \
--location=global \
--display-name="External CI pool"
# The provider trusts GitHub's OIDC issuer and pins the token to one repo + branch.
gcloud iam workload-identity-pools providers create-oidc gh \
--location=global \
--workload-identity-pool=ci-pool \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository=='acme/api' && assertion.ref=='refs/heads/main'"
# Output:
# Created workload identity pool [ci-pool].
# Created workload identity pool provider [gh].

Two flags on that provider carry all the weight. The attribute mapping copies details out of the incoming token onto Google's own labels, so the token's repository claim becomes attribute.repository, a label you can point at later when you hand out permissions. The attribute condition is the bouncer at the desk. It's written in CEL, short for Common Expression Language, a tiny language Google uses for exactly these yes-or-no checks, and it runs on every single exchange. The line above lets a token through only if its repository claim is exactly acme/api and its branch is refs/heads/main. A token from any other repository, branch, tag, or from a fork's pull request gets turned away before Google ever mints a credential.

Grant the federated identity, no key involved

Now you let that outside identity act as a real service account. In Google Cloud, every permission is handed out through IAM, its Identity and Access Management system, the thing that decides who can do what. The name you grant the permission to here isn't an email address. It's a principalSet, which is a way of naming a whole group of outside identities by one shared label. "Everyone whose repository label is acme/api" gets the right to act as the deploy account. One catch: the path needs your project's number, not its friendlier project ID.

bind principalSet to the deploy SA
# The numeric pool resource name feeds the principal path.
gcloud iam workload-identity-pools describe ci-pool --location=global --format="value(name)"
# projects/834726510983/locations/global/workloadIdentityPools/ci-pool
# Let anything matching that repo attribute impersonate the deploy SA. No key file.
gcloud iam service-accounts add-iam-policy-binding \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/834726510983/locations/global/workloadIdentityPools/ci-pool/attribute.repository/acme/api"
# Output:
# Updated IAM policy for serviceAccount [[email protected]].
# bindings:
# - members:
# - principalSet://iam.googleapis.com/projects/834726510983/locations/global/workloadIdentityPools/ci-pool/attribute.repository/acme/api
# role: roles/iam.workloadIdentityUser
# etag: BwYX8k2p9nA=
# version: 1

A principalSet names a group by label. If you'd rather pin one exact identity, principal:// names a single subject instead of a set. And strictly speaking you don't even need a service account in the middle. You can grant that principalSet direct access to a bucket or another resource, with no service account involved at all. Impersonating a service account stays the popular choice for a plain reason: most tools and client libraries already know how to read a service account's short-lived token, so nothing downstream has to be rewritten.

The workload trades its ID for a one-hour pass

Here's the moment the front desk actually prints the pass. Inside GitHub Actions you give the job permission to request its own OpenID Connect token, then you pass the provider and the target service account to Google's official auth action. Behind the scenes, the runner posts its signed token to Google's Security Token Service, an endpoint called sts.googleapis.com. That service (STS for short) checks the signature against GitHub's public keys, runs your attribute condition, and hands back a federated access token that's good for one hour. The auth step saves it as short-lived credentials the rest of the job uses without noticing. Nothing gets downloaded or written onto the runner that would still work tomorrow.

.github/workflows/deploy.yml
# Not one access-key secret anywhere in this file.
permissions:
id-token: write # allow the runner to request its GitHub OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/auth@v2
with:
project_id: acme-prod
workload_identity_provider: projects/834726510983/locations/global/workloadIdentityPools/ci-pool/providers/gh
service_account: [email protected]
- run: gcloud storage cp ./build/app.tar.gz gs://acme-artifacts/
# auth step log output:
# Created credentials file at "/home/runner/work/_temp/gha-creds-a1b2c3d4.json"
# Successfully exchanged GitHub OIDC token for a Google federated access token (expires in 3600s)
# Copying file://./build/app.tar.gz to gs://acme-artifacts/app.tar.gz
# Completed files 1/1 | 4.2MiB/4.2MiB

The same pool will take IDs from other issuers too, each through its own provider. A workload running in Amazon Web Services (AWS) can present its signed caller identity, the proof its own platform already gives it, instead of a downloaded key. You add an AWS provider to the pool, map the assumed-role name onto a label, and bind the matching principalSet exactly the way you did for GitHub. One pool, several front doors, still no keys anywhere.

federate an AWS role into the same pool
# Trust one AWS account, pinned to a single IAM role by its ARN.
gcloud iam workload-identity-pools providers create-aws aws-prod \
--location=global \
--workload-identity-pool=ci-pool \
--account-id=210987654321 \
--attribute-mapping="google.subject=assertion.arn,attribute.aws_role=assertion.arn.extract('assumed-role/{role}/')" \
--attribute-condition="attribute.aws_role=='ci-deployer'"
# Output:
# Created workload identity pool provider [aws-prod].
A loose condition hands the keys to the whole org
The attribute condition isn't a detail you can tune later. It is the whole security boundary. Pinning to assertion.repository_owner=='acme' feels safe because it names your organization, but it quietly trusts every single repository that org owns. That includes a forgotten throwaway repo, a low-value experiment nobody watches, and a repository an attacker managed to compromise. Any one of them can then mint your production deploy credentials. Worse, some GitHub triggers run a workflow with the repository's own identity even for changes proposed from a fork, so an outsider's pull request can end up standing at your front desk. Pin the exact repository, and for production pin the branch too. Review these conditions with the same care as any permission grant, because that one line is the door.
The federation trust boundary
Outside GCP (no Google key lives here)
GitHub Actions job
mints an OIDC token: repository, ref, run id
AWS workload
presents its signed assumed-role identity
The front desk: Workload Identity Federation
Provider verifies the signature
checks the token against the issuer's public keys
Attribute condition (CEL)
repository=='acme/api' && ref=='refs/heads/main'
Inside GCP
STS mints a short-lived token
~1 hour, auto-expiring, no key downloaded
principalSet impersonates the deploy SA
scoped roles only, blast radius is one account
Nothing static crosses the line. A signed claim goes in, a one-hour token comes back, and there's no key file on either side to leak or rotate.

The flow is always the same. CI presents an OIDC token from GitHub, GitLab, Terraform Cloud, or another issuer. Your provider maps claims into Google attributes. A service account trusts principals from that pool that match an attribute condition. Google issues a short-lived access token. No JSON key ever rests in a secret store to leak into a fork PR.

Tighten by repository, ref, and workflow. Prefer principalSet bindings scoped with attribute.repository and attribute.ref. Rotate by changing the condition, not by chasing files. Keep a break-glass human path that is monitored, and delete any remaining user-managed keys the week federation goes live so nostalgia does not keep the old risk warm.

Try this

Describe your workload identity pool and provider, then from CI (or a simulated token exchange) show that you can impersonate the deploy service account without a JSON key on disk.

terminal
gcloud iam workload-identity-pools describe github-pool \
--location=global --project=payments-prod
gcloud iam workload-identity-pools providers describe github-provider \
--location=global --workload-identity-pool=github-pool --project=payments-prod \
--format="yaml(oidc,attributeMapping,attributeCondition)"
gcloud iam service-accounts get-iam-policy \
[email protected] --project=payments-prod
output
name: projects/802451296328/locations/global/workloadIdentityPools/github-pool
state: ACTIVE
oidc:
issuerUri: https://token.actions.githubusercontent.com
attributeMapping:
google.subject: assertion.sub
attribute.repository: assertion.repository
attributeCondition: attribute.repository=="acme-corp/payments-api"
bindings:
- members:
- principalSet://iam.googleapis.com/projects/802451296328/locations/global/workloadIdentityPools/github-pool/attribute.repository/acme-corp/payments-api
role: roles/iam.workloadIdentityUser

Takeaway

Remember: federation trades a forever key for a short-lived token bound to the repo, branch, or workflow you allow. Attribute conditions are the real lock; a pool without them is just a wider front door.

Next you will bake org policies that make the old key path impossible, so one mistaken download cannot undo the federation work.

Quick check
01A provider condition of assertion.repository_owner=='acme', with no repository or ref pin, guards your production deploy service account. Why is that dangerous?
Incorrect — No. Org-level scoping trusts every repo the org owns, which is almost never what you want protecting a production credential.
Correct — repository_owner matches all repos under the org, so the blast radius is the whole org rather than the single repo that should deploy. Pin assertion.repository, and usually the ref, instead.
Incorrect — No. GitHub signs the token regardless of your GCP condition. The condition is evaluated on Google's side, and a broad one passes happily.
Incorrect — No. Federated tokens are short-lived either way. The problem here is who is trusted, not how long a token lasts.
02When you bind roles/iam.workloadIdentityUser to a principalSet:// member instead of a principal:// member, what have you actually granted?
Incorrect — that describes principal://, which names one subject; principalSet names a group.
Incorrect — it doesn't grant internal Google users; it matches external federated identities that share an attribute.
Correct — principalSet names a set by a shared attribute, so any token carrying that label can act as the service account.
Incorrect — the binding only grants impersonation rights; the token is produced later by the STS (Security Token Service) exchange during the workflow run, not by this command.
03An attacker reads the federated access token out of a running GitHub Actions job that authenticates via Workload Identity Federation. Compared with the old approach of a downloaded service-account key stored as a CI secret, why is the blast radius smaller?
Correct — federation returns a roughly one-hour credential and leaves no permanent key behind, so a stolen token goes stale almost immediately.
Incorrect — a static key is long-lived and portable, while a federated token expires in about an hour, a real difference in exposure.
Incorrect — the token is a bearer credential usable from anywhere; its protection is its short lifetime, not any network binding.
Incorrect — the token carries whatever the impersonated service account is allowed to do; it isn't limited to reads.

Federation removes the need for a key. It doesn't stop a tired engineer from creating one anyway, out of habit or to unstick a broken build at 2am. The next lesson shuts that door from the top. One organization policy, constraints/iam.disableServiceAccountKeyCreation, set high in the resource hierarchy and inherited by every project beneath it, turns key creation off by default. Keyless stops being a rule each team has to remember and becomes the way things simply are.

Related