Replacing Static GCP Credentials in CI/CD with Workload Identity Federation
Part 1 of 2 — Concepts and Architecture
If your GitHub Actions workflows authenticate to GCP using a stored secret — a service account key JSON, a FIREBASE_TOKEN, or any other long-lived credential — you have a static credential problem. It doesn’t matter what format the credential is in. The issue is that it exists at rest, in GitHub, and was generated by a person who may no longer work there.
This post explains how to replace every static GCP credential in your CI/CD pipelines with Workload Identity Federation (WIF): an approach where no credential is stored anywhere, and each workflow run authenticates with a short-lived token that expires automatically. Part 1 covers the concepts and the three architectural decisions you need to make before touching any CLI. Part 2 walks through the actual setup using an agent-assisted rollout.
Governing Principles
Before getting into mechanics, it helps to state the principles the design is meant to enforce. Every architectural decision in this post is traceable to one of these.
1. Hard separation between stg and prd. Stg and prd are different security domains. A credential that works in stg must not work in prd, and vice versa. This is enforced technically — separate GCP projects, separate service accounts, separate GitHub Environments — not by convention or process alone.
2. Developers have broad access to stg. Developers can push to stg, trigger stg workflows, and iterate freely. The stg environment exists to catch problems early. Friction in stg slows development without improving security.
3. Only CI/CD regularly accesses prd.
Production is changed through the automated pipeline. Developers retain the ability to access prd for incident response and admin tasks, but the normal path goes through CI/CD with an approval gate. Approval is governed by the CODEOWNERS team (frontend-eng) — at least one member who is not the author must sign off before a prd deployment proceeds.
4. Secrets follow the environment they belong to.
A secret needed only in stg lives in the stg environment scope. A secret needed only in prd lives in prd. Repo-level secrets are reserved for values that are genuinely the same across all environments. A prd secret is physically inaccessible to a workflow running in the stg environment — not by convention, but because GitHub will not inject it.
5. The credential is the last line of defence, not the first. Branch protections, environment approval gates, and WIF attribute conditions are all enforced before a credential is issued. By the time a CI/CD identity authenticates to GCP, multiple independent controls have already verified it should be running.
The Pattern and Why It Fails
GitHub Actions workflows that interact with GCP usually do two things: read secrets from Secret Manager, and deploy something (Cloud Run, Firebase Functions, Artifact Registry images, etc.). Both operations require a GCP identity.
The common way teams set this up:
- Create a GCP service account with the necessary permissions
- Generate a JSON key for that service account
- Base64-encode it and store it as a GitHub secret (
GCP_SA_KEY,GOOGLE_CREDENTIALS, or similar) - In the workflow, decode and activate it:
gcloud auth activate-service-account --key-file
Some teams use FIREBASE_TOKEN (a personal OAuth2 refresh token) for Firebase deployments specifically. Different format, same problem.
What these credentials have in common:
- Long-lived: SA keys don’t expire unless explicitly rotated. Firebase tokens are valid until revoked.
- Tied to a person or a manual process: someone generated the key, someone stored it, someone needs to remember to rotate it.
- Static: sitting in GitHub secrets at rest, accessible to anyone with admin access to the repo or to any workflow that can read secrets.
- Broad: SA keys are often over-provisioned (“just give it Editor, we’ll tighten it later”).
For PCI DSS compliance, this creates specific gaps in three requirements:
- Requirement 7 (restrict access): a stored SA key grants persistent access to GCP resources, not access scoped to a specific workflow run
- Requirement 8 (identify and authenticate): automated systems should use machine identities with defined lifecycles, not keys tied to individuals or generated ad-hoc
- Requirement 10 (log and monitor): when multiple workflows share a credential or use a personal token, audit logs lose attribution — you see the SA email, not what triggered the action
What Workload Identity Federation Does Differently
Instead of storing a credential, WIF lets a GitHub Actions runner prove what it is to GCP in real time, using a signed token that GitHub issues for each workflow run.
Here is the complete flow:
1. The workflow starts a job.
GitHub generates a short-lived OIDC JSON Web Token (JWT) for this specific run. The token is signed by GitHub’s private key and contains claims about the run:
| |
This token cannot be forged — GCP can verify it against GitHub’s public keys published at a known URL.
2. The google-github-actions/auth step presents this JWT to GCP.
It calls the GCP Security Token Service (STS): “I have this JWT from GitHub. I want credentials for the service account bound to my identity.”
3. GCP validates the JWT and checks the attribute condition.
The Workload Identity Pool trusts tokens from token.actions.githubusercontent.com. GCP verifies the signature, checks the expiry, and evaluates the condition on the service account’s IAM binding. For example:
google.subject == "repo:your-org/your-repo:environment:prd"
If the condition passes, GCP issues short-lived credentials (valid for 1 hour) for that service account.
4. The workflow runs using Application Default Credentials (ADC).
google-github-actions/auth writes the short-lived credentials to the environment. Every GCP tool — gcloud, Firebase CLI, Cloud Run deploy, Secret Manager SDK — picks these up automatically. No key file. No FIREBASE_TOKEN. No --key-file flag.
The result: credentials that exist for one hour, for one workflow run, for one specific repo and environment. Nothing to store. Nothing to rotate. Nothing to exfiltrate.
One Identity Per CI/CD Pipeline, Per Environment
The natural unit of WIF setup is: one service account per (GCP project, environment) pair. Each repo’s CI/CD pipeline gets two identities — one for staging, one for production — each with the minimum permissions that pipeline actually needs.
ops-pcioasis-global
└── WIF Pool: "github-actions-pool" ← one pool, shared by all repos
└── Provider: "github-oidc"
project-foo-stg
└── SA: github-cicd@project-foo-stg ← reads secrets, deploys to stg
└── IAM binding: environment == "stg" in repo "org/project-foo"
project-foo-prd
└── SA: github-cicd@project-foo-prd ← reads secrets, deploys to prd
└── IAM binding: environment == "prd" in repo "org/project-foo"
The permissions on each SA depend on what the pipeline does. A repo that deploys Firebase Functions needs different roles than one that pushes Docker images to Artifact Registry and deploys to Cloud Run. The examine phase (Part 2) maps what each workflow actually does before assigning roles.
The Three Decisions You Need to Make
Decision 1: Where does the WIF Pool live?
The pool’s only job is to establish trust in GitHub’s OIDC issuer. You create it once, in one project, and every service account across all your projects can reference it.
Create one pool in your shared operations project. For an org with 20 repos and 40 GCP projects, a per-project pool approach means 40 identical configurations to maintain. A single pool means one.
Decision 2: Branch conditions or GitHub Environments?
The IAM binding condition on each service account determines which GitHub identity can use it. Two options:
Branch-based: attribute.ref == "refs/heads/main" — only workflows triggered on main can use the prd SA. Simple, no extra GitHub configuration.
GitHub Environments: google.subject == "repo:org/repo:environment:prd" — only a workflow job that declares environment: prd can use the prd SA. Requires setting up GitHub Environments in each repo, but adds approval gates.
For PCI systems, use GitHub Environments. Approval gates create a documented human checkpoint before every production change. The GitHub environment audit log records who approved each deploy — that is auditor-visible evidence.
Decision 3: What permissions does each SA need?
This is determined by what the workflow actually does. Common patterns:
| Workflow type | Minimum roles |
|---|---|
| Read secrets from Secret Manager | roles/secretmanager.secretAccessor |
| Deploy Firebase Functions | roles/firebase.admin, roles/cloudfunctions.developer, roles/iam.serviceAccountUser |
| Push to Artifact Registry + deploy Cloud Run | roles/artifactregistry.writer, roles/run.developer, roles/iam.serviceAccountUser |
| All of the above | Combine the above — still no roles/editor |
The examine phase maps what each workflow does before you assign roles. Do not assign roles/editor as a shortcut — it grants write access to almost every GCP service and will fail a PCI review.
How Isolation Is Enforced
Each service account’s IAM binding has an attribute condition:
google.subject == "repo:pci-tamper-protect/project-foo:environment:prd"
When a workflow job requests credentials, GCP checks this condition against the OIDC token claims. If the job is running in the stg environment (or any other environment, or no environment at all), the condition does not match and credentials are refused. There is no way to bypass this from within a workflow — changing IAM bindings requires GCP IAM permissions the workflow does not have.
The stg SA for one repo cannot be used by a different repo. The prd SA for one repo cannot be used by a stg workflow from the same repo. Each identity is precisely scoped.
What a PCI Auditor Sees
Under the static credential pattern, the auditor asks how you ensure only authorized personnel deploy to production. The answer describes a human process — someone generated a key, someone stored it, someone has to remember to rotate it.
Under WIF with GitHub Environments:
- Req 7: each SA has the minimum permissions for its specific workflow, not broad project-level access
- Req 8: machine identities with defined scope and zero stored credentials replace ad-hoc personal tokens and shared SA keys
- Req 10: every production deploy is logged in GitHub’s environment audit with timestamp, commit, and approver
The control is technical and demonstrable, not procedural.
Part 2
Part 2 → covers the agent-assisted rollout: examining what GCP credentials exist across your repos, mapping each workflow to its required permissions, and executing the setup one repo at a time with human review at every IAM binding.
PCI Oasis publishes engineering content for teams building and operating PCI-compliant systems.