GitHub Actions + GCP WIF: Least Privilege Across Branches, Workflows, and Service Accounts
When I connect GitHub Actions to GCP now, there is one setup I do not want to repeat:
- one shared deploy service account
- one broad trust rule
- one workflow that can hit every environment
It feels convenient at first. But once you split dev / prod, risk rises fast and debugging gets more expensive.
This is the model I ended up with: enforce least privilege through three aligned boundaries โ branch, workflow, and service account.
Login is not the hard part; trust boundaries are
When people first implement OIDC / WIF, the question is often:
- how do I let GitHub Actions authenticate to GCP?
That solves only half of the problem.
The real questions are:
- which workflow can impersonate which service account?
- from which branch?
- against which environment?
- with which permissions?
If those boundaries are unclear, all you did was replace long-lived credentials with short-lived ones. You still did not enforce trust boundaries cleanly.
The service account model I ended up using
I split the roles into three groups:
|
|
Core principles:
- CI should not get cloud credentials if it does not need them
- infra should not share a service account with application deployment
- dev and prod deployments should not share the same service account
How I split CI job responsibilities
Workflow boundaries:
|
|
Then map branch boundaries:
|
|
That keeps branch policy and environment policy aligned.
What the federated auth gate should enforce
The provider layer is the right place to gate which workflow-and-branch combinations are allowed in.
Conceptually:
|
|
With this in place, disallowed tokens fail before the token exchange step.
Why gate-layer checks alone are not enough
A provider only sees token claims. It does not see workflow runtime intent.
For example, infra.yml may accept a target input:
shareddev-platformprod-platform
The provider cannot see this input, so it cannot enforce this on its own:
sharedmust be main onlydev-platformmust be dev only
So that check has to live inside the workflow itself:
|
|
This is the defense-in-depth model I use:
- workflow validation
- provider condition
- service account binding
workflow_ref and caller_workflow โ a Subtle Pitfall
This is one of the easiest mistakes in WIF claim design.
If a workflow calls a reusable workflow, job_workflow_ref points to the reusable workflow, not the top-level entry workflow.
If you bind on the wrong claim, you can get this failure mode:
- trust rule technically passes
- but the wrong workflow path is bound
The safer pattern I use:
|
|
Then bind service account access against this caller workflow path.
That keeps reusable workflow path from getting mixed up with the top-level workflow path.
Why I eventually removed shared deploy flows here
This is slightly outside WIF itself, but it directly affected the design.
Reusable workflows are not inherently wrong. The problem is that they can make these questions less obvious:
- who owns permissions
- where
id-token: writeis actually needed - which workflow is the real operator entry point
- what appears in the GitHub Actions UI
So I accepted a bit of duplication and flattened the deploy layer back to:
deploy-dev.ymldeploy-prod.yml
Then I kept shared steps in composite actions.
After this change, least-privilege review became much more straightforward.
id-token: write โ Avoid Granting It Too Broadly
Another rule I follow now: do not grant id-token: write at workflow scope unless the entire workflow truly needs it.
A cleaner pattern:
- CI: no id-token
- validate job: no id-token
- only build/migrate/deploy jobs that need auth get id-token
|
|
This avoids giving OIDC capability to branch-validation jobs by accident.
Env-Scoped Secrets in CI Platforms Are Part of the Trust Perimeter Too
This is frequently overlooked.
If a job needs environment-scoped variables, the job must explicitly declare the environment:
|
|
This is not just about variable lookup. It is also a trust boundary, because GitHub environments can enforce approval rules.
So environment-scoped variables, deploy service accounts, and branch guards are different layers of the same security model.
How I now test whether least privilege is actually enforced
I ask these questions:
- Can PR CI run without any cloud credential?
- Can dev deploy impersonate only the dev deploy SA?
- Can prod deploy impersonate only the prod deploy SA?
- Can infra impersonate only the infra SA?
- Does the wrong branch fail before real work begins?
- Does the provider reject disallowed workflow+branch combinations?
If any answer is unclear, the model is still not clean enough.
Conclusion
The hard part of GitHub Actions + GCP is not merely making login work.
The hard part is enforcing trust boundaries clearly:
- correct workflow
- correct branch
- correct service account
- correct environment
- correct permissions
Only when these layers hold together do you actually achieve least privilege.