
A GCP service account JSON key stored as a GitHub secret works forever, for anyone who has it, until someone remembers to rotate it. It doesn't expire on its own, it doesn't know which workflow is supposed to use it, and if it ever leaks — in a log line, a misconfigured debug step, a fork's pull request — whoever has it can authenticate as that service account from anywhere, indefinitely. Workload Identity Federation (WIF) removes the key entirely: GitHub Actions already proves its own identity with a short-lived OIDC token on every run, and WIF lets GCP trust that token directly, exchanging it for a GCP access token that's valid for the one job, nothing stored anywhere in between.
Step 1: Create a Workload Identity Pool
A pool is a container for external identity providers you trust. One pool can hold multiple providers (GitHub, GitLab, another cloud) if you need them later:
gcloud iam workload-identity-pools create "github-pool" \
--project="my-project" \
--location="global" \
--display-name="GitHub Actions Pool"
Step 2: Add GitHub as a provider — and scope it tightly
This is the step that actually matters for security. The --attribute-condition flag restricts which GitHub repository is allowed to use this provider at all — skip it, and any GitHub Actions workflow in any organization could potentially request a token against your pool, since GitHub's OIDC issuer is shared infrastructure, not something scoped to your account:
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=='myorg/myrepo'" \
--issuer-uri="https://token.actions.githubusercontent.com"
The attribute mapping pulls repository and ref out of GitHub's OIDC claims and exposes them as attributes GCP's IAM policy can reference — attribute.repository is what the binding in Step 4 matches against. If you want to restrict to a specific branch too (not just a repository), extend the condition: assertion.repository=='myorg/myrepo' && assertion.ref=='refs/heads/main'.
Step 3: Create the service account this workflow will act as
gcloud iam service-accounts create github-deployer \
--project="my-project" \
--display-name="GitHub Actions deployer"
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:github-deployer@my-project.iam.gserviceaccount.com" \
--role="roles/run.admin"
Grant only the roles this specific workflow actually needs — roles/run.admin here is illustrative for a Cloud Run deploy; scope it to whatever the real job does. A service account created for one CI job doesn't need project-owner, and WIF doesn't change that — it changes how the credential is obtained, not what it's allowed to do once obtained.
Step 4: Let the GitHub provider impersonate that service account — and only that repo
This is the binding that actually connects the OIDC identity to the GCP service account. Get your project number first (the member string needs it, not the project ID):
gcloud projects describe my-project --format='value(projectNumber)'
gcloud iam service-accounts add-iam-policy-binding \
"github-deployer@my-project.iam.gserviceaccount.com" \
--project="my-project" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/myorg/myrepo"
The principalSet://.../attribute.repository/myorg/myrepo member means "any token from this pool whose repository attribute is exactly myorg/myrepo" — combined with the attribute condition from Step 2, this is the actual security boundary: two separate places both have to agree the repository matches before a token is issued and before it can impersonate anything.
Step 5: Use it from GitHub Actions
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # required — this is what lets GitHub mint the OIDC token
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
service_account: 'github-deployer@my-project.iam.gserviceaccount.com'
- uses: google-github-actions/setup-gcloud@v2
- name: Deploy
run: |
gcloud run deploy my-service \
--image gcr.io/my-project/my-service:${{ github.sha }} \
--region us-central1
No GOOGLE_APPLICATION_CREDENTIALS, no stored JSON key, nothing in the repository's secrets at all. The auth step exchanges the job's GitHub OIDC token for a short-lived GCP access token behind the scenes, and every later step in the job picks it up automatically through the standard Application Default Credentials chain.
Verifying it actually worked
A successful auth step output includes the impersonated service account's email. If deploys are succeeding, that's usually confirmation enough — but to check the binding independently, Cloud Audit Logs for the service account will show the principal field populated with the pool/provider path rather than a user or key identity, confirming the token came from WIF and not a leftover key somewhere.
Troubleshooting
permission denied on resource (or it may not exist)during auth. Almost always theprincipalSetmember in Step 4 not matching the actualattribute.repositoryvalue exactly — check case and that it'sowner/repo, not a URL or a different separator.Unable to acquire impersonated credentials. Usually meansid-token: writeis missing from the workflow'spermissionsblock — without it, GitHub never issues the OIDC token the auth action needs.- Works on your branch, fails on a fork's PR. Expected, and correct — a fork's workflow run has a different
repositoryclaim (or runs with reduced permissions entirely), so it won't match the condition. Don't loosen the condition to "fix" this; a looser condition is exactly the hole WIF exists to close. - Provider created but nothing authenticates. Double-check the pool and provider are both referenced by their full resource path in the workflow, including the project number (not the project ID) — a project ID in that path silently fails to match.
Where to go next
The same pattern extends past GitHub Actions — GitLab CI, CircleCI, and most other CI platforms that issue their own OIDC tokens can be added as additional providers on the same pool, each with its own attribute condition, so a growing set of CI systems can all deploy to GCP without a single long-lived key existing anywhere.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


