GCP Workload Identity Federation: Credential-Free Access from Any Identity Provider

GCP Workload Identity Federation: Credential-Free Access from Any Identity Provider

The Service Account Key Problem

Every GCP service account can have up to ten downloadable JSON key files. These files contain an RSA private key that never expires by default and grants any bearer the permissions attached to the account. They are credentials in the truest sense: whoever holds the file is the service account.

This creates a class of problems that are hard to eliminate once a codebase relies on key files:

Rotation burden. Keys must be manually rotated or scripted with gcloud iam service-accounts keys create. Rotation requires coordination: the new key must be distributed before the old one is deleted. In practice, keys accumulate. A service account with the maximum ten keys almost certainly has several forgotten ones issued for a job that no longer exists.

Key sprawl. JSON key files get copied. They appear in CI environment variables, in Kubernetes secrets, in .env files checked into private (and sometimes not-so-private) repositories, in developer home directories, in Slack messages when someone is debugging a credential issue at 11pm. Once a key leaves the original distribution channel, tracking every copy is not tractable.

Exfiltration risk. A key file is static. It does not expire after a session, it is not tied to a network location, and it generates no audit event merely by being held. An attacker who exfiltrates a key file from a compromised CI runner has indefinite access to the service account’s permissions until someone notices and deletes the key.

No source binding. A key file obtained from a GitHub Actions runner is indistinguishable from the same file used from an attacker’s machine in another country. There is no mechanism to restrict a key to the workload that was meant to use it.

Workload Identity Federation eliminates key files entirely by replacing them with short-lived tokens derived from the workload’s own identity credential — the OIDC token issued by GitHub Actions, the EC2 instance identity document, the Kubernetes service account token. The GCP token has a one-hour lifetime, is issued per-request, and is cryptographically bound to the external identity that requested it.

Workload Identity Pool and Provider

Federation in GCP is configured through two constructs: the Workload Identity Pool and the Workload Identity Provider.

The pool is a container for a set of external identities. It belongs to a project and has a name that appears in the principal URLs for every identity it contains. You can think of it as a trust domain: identities inside the same pool share the same attribute namespace and can be granted IAM roles using pool-scoped principal identifiers.

gcloud iam workload-identity-pools create "github-pool" \
  --project="my-project" \
  --location="global" \
  --display-name="GitHub Actions pool"

The provider defines the external identity source within a pool. Each provider has a type — OIDC or AWS — and carries the configuration needed to validate credentials from that source: the issuer URL for OIDC providers, the AWS account ID for AWS providers. A pool can have multiple providers, so you can accept identities from GitHub Actions and AWS EC2 in the same pool without mixing their attribute namespaces.

gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --project="my-project" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --attribute-condition="assertion.repository_owner == 'my-org'" \
  --issuer-uri="https://token.actions.githubusercontent.com"

Attribute Mapping and Conditions

Attribute mapping is the bridge between the claims in an external token and the attributes GCP uses for IAM decisions. The left side of each mapping is a GCP attribute name; the right side is a CEL expression evaluated against the incoming token’s claims.

google.subject is the only required mapping. It becomes the unique identifier for the principal within the pool and appears in audit logs. For GitHub Actions, assertion.sub looks like repo:my-org/my-repo:ref:refs/heads/main. For a Kubernetes service account token (when used with an external cluster provider), it typically looks like system:serviceaccount:namespace:name.

Custom attributes use the attribute. prefix. They are not used by GCP internally but you can reference them in IAM bindings using principal set expressions. attribute.repository extracted from assertion.repository lets you write an IAM binding that grants a role only to identities whose repository claim matches a specific value.

principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo

The attribute condition is a CEL expression evaluated against the incoming token before any token exchange is permitted. If the expression evaluates to false, GCP rejects the request and no GCP token is issued. This is the correct place to enforce coarse-grained trust boundaries.

A condition that restricts federation to a specific GitHub organisation:

assertion.repository_owner == 'my-org'

A tighter condition that also restricts to protected branches:

assertion.repository_owner == 'my-org' && (assertion.ref == 'refs/heads/main' || assertion.ref.startsWith('refs/heads/release/'))

The attribute condition evaluates before IAM, so it limits which identities can even attempt to exchange a token. IAM conditions on the role binding then control what GCP resources those identities can access.

GitHub Actions to GCP Federation

GitHub Actions workflows have access to a short-lived OIDC token issued by GitHub’s token endpoint. The token contains claims about the repository, branch, commit SHA, triggering event, environment, and actor. GCP’s google-github-actions/auth action handles the token exchange without any stored credentials in the workflow.

The IAM binding grants the GitHub identity permission to impersonate a service account. The service account itself holds the GCP resource permissions. This separation is intentional: the pool principal gets only roles/iam.workloadIdentityUser on the service account, and the service account gets the narrow permissions it needs.

gcloud iam service-accounts add-iam-policy-binding "deploy-sa@my-project.iam.gserviceaccount.com" \
  --project="my-project" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"

The workflow:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: "projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
          service_account: "deploy-sa@my-project.iam.gserviceaccount.com"

      - uses: google-github-actions/setup-gcloud@v2

      - run: gcloud run deploy my-service --image gcr.io/my-project/my-service:${{ github.sha }} --region us-central1

The id-token: write permission is required for GitHub to expose the OIDC endpoint to the workflow. Without it, the ACTIONS_ID_TOKEN_REQUEST_URL environment variable is not set and the auth action cannot obtain the GitHub token.

The token exchange flow: the auth action requests a GitHub OIDC token from GitHub’s token endpoint, then sends it to the GCP Security Token Service (sts.googleapis.com) with the provider resource name. GCP validates the token’s signature against GitHub’s JWKS endpoint, evaluates the attribute condition, maps claims to attributes, and issues a short-lived federated access token. If service account impersonation is configured, the action then calls iamcredentials.googleapis.com/generateAccessToken to exchange the federated token for a service account access token. Both steps produce tokens with a one-hour lifetime.

GKE Workload Identity

Pods running on GKE need GCP resource access too. The historical approach was to mount a service account key as a Kubernetes Secret and present it to GCP APIs via the client library’s ADC chain. The problem is identical to the key file problem described above: the key is static, broadly accessible within the namespace, and not tied to the pod.

GKE Workload Identity (referred to as Workload Identity in GKE documentation, and Workload Identity Federation for GKE in newer contexts) solves this at the node level. The mechanism differs from the federation pattern above because GKE handles the token projection and exchange transparently.

Enable Workload Identity on the cluster:

gcloud container clusters update my-cluster \
  --region us-central1 \
  --workload-pool="my-project.svc.id.goog"

Enable on the node pool:

gcloud container node-pools update default-pool \
  --cluster my-cluster \
  --region us-central1 \
  --workload-metadata=GKE_METADATA

Create the binding between the Kubernetes ServiceAccount and the GCP Service Account:

gcloud iam service-accounts add-iam-policy-binding "app-sa@my-project.iam.gserviceaccount.com" \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:my-project.svc.id.goog[my-namespace/my-ksa]"

Annotate the Kubernetes ServiceAccount:

kubectl annotate serviceaccount my-ksa \
  --namespace my-namespace \
  iam.gke.io/gcp-service-account=app-sa@my-project.iam.gserviceaccount.com

The GKE metadata server runs as a DaemonSet on each node. When a pod makes a request to the GCP metadata endpoint (metadata.google.internal), the metadata server intercepts it, uses the pod’s Kubernetes ServiceAccount token to perform an STS token exchange, and returns a GCP access token scoped to the bound GCP service account. Application code using the GCP client libraries picks this up automatically through Application Default Credentials — no configuration change required beyond the annotation.

The pod spec needs the correct service account:

apiVersion: v1
kind: Pod
spec:
  serviceAccountName: my-ksa
  containers:
    - name: app
      image: gcr.io/my-project/app:latest

No volume mounts, no secret references, no environment variables carrying key material. The metadata server handles the full token lifecycle.

AWS to GCP Federation

An EC2 instance or ECS task with an instance profile can authenticate to GCP using its AWS identity as the external credential. The provider type is AWS rather than OIDC, and GCP validates the caller’s AWS identity through the AWS STS GetCallerIdentity API.

gcloud iam workload-identity-pools providers create-aws "aws-provider" \
  --project="my-project" \
  --location="global" \
  --workload-identity-pool="aws-pool" \
  --account-id="123456789012"

The --account-id flag restricts the provider to a single AWS account. GCP will reject credentials from any other account regardless of whether they pass AWS validation.

The google.subject for an AWS provider is the ARN of the assumed role session: arn:aws:sts::123456789012:assumed-role/my-ec2-role/instance-id. IAM bindings and attribute conditions can filter on role name or instance ID components extracted from this ARN.

Attribute conditions for the AWS provider work against the assertion object, which in this case contains the resolved caller identity fields. A condition restricting to a specific role:

assertion.arn.startsWith('arn:aws:sts::123456789012:assumed-role/allowed-role/')

On the AWS side, no additional configuration is required. The EC2 instance uses its instance profile credentials with the GCP client library’s AWS credential source, which orchestrates the GetCallerIdentity call and token exchange. For workloads running on AWS EKS, the equivalent pattern is AWS IRSA, which maps pod-level identities rather than node-level instance profiles.

Impersonation vs Direct Access

The federation patterns above all follow an impersonation model: the external identity (GitHub Actions workflow, EKS pod, EC2 instance) exchanges its native token for a short-lived GCP token, then uses that token to call GenerateAccessToken on a GCP service account, which issues a service account access token. The resource permissions sit on the service account, not on the pool principal.

The alternative is direct access: granting IAM roles directly to the pool principal rather than to a service account. The federated token is used directly to call GCP APIs without impersonation.

gcloud projects add-iam-policy-binding my-project \
  --role="roles/storage.objectViewer" \
  --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"

Direct access is simpler but has a practical limitation: you cannot reuse the same permission set across multiple external identity sources without duplicating bindings. If your GitHub Actions workflows and your AWS EC2 instances both need the same GCS bucket access, direct access requires two bindings; impersonation through a shared service account requires one. The impersonation model also produces cleaner audit logs — the GCP service account name appears in resource access logs, making the logs legible without cross-referencing the workload identity pool configuration.

For zero trust architecture compliance, both models satisfy the credential-free requirement, but impersonation through narrowly scoped service accounts aligns better with least-privilege because you can grant different service accounts to different attribute combinations with surgical precision.

Auditing Federated Identity Usage

Cloud Audit Logs records every token exchange and every impersonation call. The key log types:

Data Access logs on IAM (iam.googleapis.com) record GenerateAccessToken calls when a federated identity impersonates a service account. Enable these in the project’s audit config:

gcloud projects get-iam-policy my-project --format=json | \
  jq '.auditConfigs'

To enable Data Access logging for the IAM API programmatically, apply a policy with an auditConfigs entry for iam.googleapis.com including DATA_READ log type.

System event logs from sts.googleapis.com record the token exchange itself. These appear automatically and do not require additional enablement.

In Cloud Logging, filter for impersonation calls from a specific federated subject:

resource.type="service_account"
protoPayload.methodName="GenerateAccessToken"
protoPayload.authenticationInfo.principalSubject=~"principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/github-pool"

To filter to a specific repository:

resource.type="service_account"
protoPayload.methodName="GenerateAccessToken"
protoPayload.authenticationInfo.principalSubject=~"attribute.repository/my-org/my-repo"

The principalSubject field in the audit log carries the full pool principal URL, which encodes the attribute values. This means you can query by repository, branch, or any mapped attribute without a separate lookup. Exported to BigQuery via Log Sinks, this data supports long-term analysis of which workloads access which service accounts.

For GKE Workload Identity, the audit logs show the Kubernetes ServiceAccount identifier in the principalSubject field: serviceAccount:my-project.svc.id.goog[my-namespace/my-ksa]. This makes it straightforward to correlate GCP resource access with Kubernetes workloads.

Common Mistakes

Overly broad attribute conditions. Omitting the attribute condition entirely — or writing one that always evaluates to true — means any identity whose token passes OIDC validation can exchange a token against your pool. For GitHub Actions this means any workflow in any repository on GitHub could potentially get a token (if the issuer check passes). The condition must be specific: at minimum restrict to your organisation’s namespace, and for high-value environments restrict further to specific repositories or branches.

Missing condition expressions on IAM bindings. Even with a restrictive attribute condition on the provider, IAM bindings that use principalSet without attribute selectors grant access to every identity in the pool. A binding like principalSet://iam.googleapis.com/.../github-pool/* grants access to all identities in the pool regardless of repository or branch. Use attribute selectors: principalSet://iam.googleapis.com/.../github-pool/attribute.repository/my-org/my-repo.

Conflating pool-level and provider-level conditions. The attribute condition on the provider rejects tokens before they become pool principals. IAM conditions on bindings further restrict which pool principals can access resources. Both are necessary; neither is sufficient alone. A provider with no condition but a binding with an attribute selector still allows any identity to exchange a token — it just cannot use the resulting token to access the bound resource. During that exchange, the STS call succeeds and is logged, which inflates your audit noise and may represent a reachable attack surface if you add more permissive bindings later.

Not enabling Data Access audit logs. Admin Activity logs are always on, but Data Access logs — which include GenerateAccessToken — must be explicitly enabled. Without them, you have no record of which workloads impersonated which service accounts and when. Enable Data Access logs for iam.googleapis.com in every project where service account impersonation occurs.

Reusing a single service account across unrelated workloads. If the GitHub Actions deploy workflow and the nightly data pipeline both impersonate automation-sa@my-project.iam.gserviceaccount.com, a compromise of either workload grants the other’s permissions. Separate service accounts with separate IAM bindings keep the blast radius of a compromised workflow credential isolated. The additional management overhead is low; the containment benefit is concrete.