Killing the Service Account Key: Enforcing Workload Identity Federation for GitHub Actions

November 20, 2021

Most repos I audit have a GitHub Actions secret called something like GCP_SA_KEY — a base64-encoded JSON blob that gives a Service Account broad access to a GCP project. The key has usually been there for years, nobody's rotated it, and at least three former employees know what it looked like.

Workload Identity Federation lets you delete that secret entirely. It's been generally available long enough that there's no reason not to use it on new pipelines, and the migration on existing ones is short. Here's what it actually involves.

Why long-lived service account keys are the wrong default

The legacy workflow:

  1. Create a Service Account.
  2. Generate a JSON key file.
  3. Paste it into GitHub Secrets.
  4. Hope nobody commits it to the repo.
  5. Forget about it for several years.

If that key leaks — committed by accident, exfiltrated from a compromised laptop, exposed by a misconfigured CI log — the attacker has whatever permissions the SA has, for as long as the key is valid. Detection is usually slow. Revocation requires noticing.

What Workload Identity Federation does instead

The model is trust-based. GCP trusts GitHub as an OIDC identity provider. When a workflow runs, GitHub mints a short-lived OIDC token that includes claims about the run (repo, ref, environment). GCP validates the token, checks attribute conditions you've configured, and exchanges it for a short-lived GCP access token scoped to a specific Service Account.

What changes:

  • No long-lived secret stored in GitHub.
  • Tokens expire in an hour. A leaked token is briefly useful, not indefinitely useful.
  • You can scope access by repository, branch, environment, or any other claim in the OIDC token.

The flow

   GitHub Actions          GCP Auth Action         Google Cloud (IAM)
                                                          
         1. Generate OIDC token                           
            (signed by GitHub,                            
             claims: repo+ref)                            
        │────────────┐                                     
        │◄───────────┘                                     
                                                          
         2. Send OIDC token to Workload Identity Pool      
        │──────────────────────────────────────────────────►│
                                                          
                                   3. Validate signature  
                                      + attribute mapping 
                                             ┌────────────│
                                             └───────────►│
                                                          
                                   4a. Valid repo:        
                                       short-lived GCP    
                                       access token       
                                 │◄────────────────────────│
                                                          
                                   4b. Invalid repo:      
                                       403 Forbidden      
                                 │◄────────────────────────│
                                                          
          5. Authenticate gcloud                          
             / Terraform w/ tok.                          
        │◄────────────────────────│                         
                                                          

Setup, three steps

1. Create the Workload Identity Pool and a GitHub provider.

  • Issuer URI: https://token.actions.githubusercontent.com
  • Attribute mapping: google.subject=assertion.sub, attribute.repository=assertion.repository

Add an attribute condition to restrict to your org, e.g. assertion.repository_owner == "your-org". Without this, any repo on github.com that knows your pool name can attempt to authenticate.

2. Allow the pool to impersonate the Service Account. The pool itself doesn't get any direct GCP permissions. You bind a member to the existing Service Account:

  • Member: principalSet://iam.googleapis.com/projects/{PROJECT_NUM}/locations/global/workloadIdentityPools/{POOL}/attribute.repository/{ORG/REPO}
  • Role: roles/iam.workloadIdentityUser

3. Update the GitHub Actions workflow. Use google-github-actions/auth. No keys:

- id: 'auth'
  name: 'Authenticate to Google Cloud'
  uses: 'google-github-actions/auth@v0'
  with:
    workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider'
    service_account: 'my-service-account@my-project.iam.gserviceaccount.com'

- name: 'Use gcloud'
  run: 'gcloud info'

Three things that cost me an afternoon

Subject mapping. google.subject must map to assertion.sub. Trying to be clever and mapping it to something else breaks the token exchange in non-obvious ways. Keep the primary mapping standard; use additional attributes for filtering.

Case sensitivity. GitHub's OIDC claims are case-sensitive on the way in but the values you put in IAM bindings are not always treated identically. Use lowercase consistently in IAM bindings and you'll avoid one class of "why doesn't this match" debugging.

Enable the IAM Credentials API. iamcredentials.googleapis.com has to be enabled on the project. The error you get if it's not is vague and points in the wrong direction.

The takeaway

CI/CD compromise is a supply-chain compromise. Removing long-lived keys is the highest-leverage thing you can do this week — it's about a 30-minute setup per repo and you stop worrying about key rotation. If you have a GCP_SA_KEY secret sitting in a repo right now, that's the next thing to fix.