← Back to Index
↳ Project /29GCP · Platform · Security

Workload Identity Federation (GCP)

CI that authenticates to Google Cloud with no service account key anywhere, and an org policy that makes creating one impossible even for a project owner. Four federation paths built side by side so the trade-offs are visible rather than asserted, deployed against a live organization and destroyed the same day.

Role
Cloud / Platform Security
Cloud
GCP
Paths
4 (GitHub ×2, AWS ↔ GCP)
Resources
41 (Terraform)
GitHub Actions
OIDC token · repo + ref
AWS IAM Role
signed GetCallerIdentity
Pool Provider
attribute condition
principalSet
read by repo · write by ref
gh-deployer
impersonation, for contrast
Secret Manager
CMEK encrypted
Artifact Registry
same key
GCS
write on main only
Org Policy
key creation impossible
A signed token becomes an hour-long credential, and the pool refuses everything else at the door

/01Problem

A service account key is a JSON file holding a private key that does not expire. It authenticates as its service account from anywhere on the internet, forever, until someone notices and revokes it. Leaked keys are found in public repositories within minutes and are a routine root cause of cloud incidents.

The usual mitigations are procedural: do not commit keys, rotate them, scan for them. Every one of them depends on nobody making a mistake. Federation removes the key entirely, but it introduces a subtler failure: a federation pool configured without constraints is worse than a key, because it trusts every token its issuer signs, and GitHub's issuer signs one for every workflow run in every repository on GitHub.

/02Approach

  • GitHub Actions to GCP through direct resource access, where a principalSet holds IAM roles on the bucket, registry, and secret and no service account exists at all.
  • The same token exchanged for a service account through roles/iam.workloadIdentityUser, built only so the two can be compared and the choice defended.
  • AWS to GCP through a pool that verifies a signed GetCallerIdentity request against AWS STS, which is why that provider takes an account ID rather than an issuer URL.
  • GCP to AWS through web identity federation, where AWS treats accounts.google.com as a built-in provider, so trust pins to the service account's numeric unique ID rather than its email.

/03The control is the attribute condition

Attribute mapping renames claims. It does not decide who gets in. The provider pins both repository and owner, so a token from any other repository is refused at the pool before any IAM binding is evaluated. Deny at the door, then scope inside it.

Authority then splits across two attributes of the same pool: read binds on attribute.repository, write binds on attribute.ref. That is what lets a pull request from a fork read without being able to write, and it is why pinning the sub claim to a single value, which is what one Checkov rule wants, would have been a downgrade rather than a hardening. The waiver is written into both the code and the README with that reasoning.

/04Making the claim enforceable

Two org policy constraints, disableServiceAccountKeyCreation and disableServiceAccountKeyUpload, are applied to the workload project. With both enforced, nobody can create or upload a key there, including a project owner. That is the difference between "we do not use keys" and "keys cannot exist here."

Secret Manager and Artifact Registry share one customer-managed key, so disabling a single key version revokes both at once with no IAM edit and nothing deleted. State lives in a separate project no federated identity can reach, because state describes the shape of everything and CI never needs to read it.

/05What the first apply taught

  • resourcemanager.organizationAdmin does not include folders.create. It administers IAM policy; it does not create hierarchy.
  • Org policy administration is a separate role again, orgpolicy.policyAdmin, and is also absent from organizationAdmin.
  • User credentials with no quota project bill orgpolicy calls to Google's shared OAuth client project, failing with SERVICE_DISABLED on a project ID that looks alarming and is not yours. Fixed with billing_project and user_project_override on the provider.
  • The Secret Manager service agent does not exist at the moment the KMS grant references it. It resolves on retry; the lag is real and undocumented in the obvious places.

/06Verified, then destroyed

The deployed provider's attribute condition was read back from the API and matched what was written. Both constraints reported enforce = True. Both projects had zero default networks. The secret was confirmed bound to the customer-managed key.

The check that matters ran last: creating a service account key as project owner was refused with constraints/iam.disableServiceAccountKeyCreation named in the violation. Everything was then destroyed, leaving both projects in DELETE_REQUESTED with billing unlinked, the folder gone, and no residual buckets.

↳ Run Receipt/29
Provision19 bootstrap resources (folder, two projects, state and log buckets, audit configs), then 22 federation resources
ProofAttribute condition confirmed on the live provider; both org policy constraints enforcing; zero default networks; CMEK binding confirmed via API
DeniedService account key creation refused as project owner, constraint named in the violation
Destroy22 then 20 resources destroyed; folder and buckets gone, both projects DELETE_REQUESTED with billing unlinked
Costunder $0.05 for the full deploy-demo-destroy cycle
Workload Identity FederationOrg PolicySecret ManagerCloud KMSArtifact RegistryGitHub OIDCAWS STSTerraform
StatusCheckingDeployed2026-09-20 13:33 UTCVisitsOriginPrivate S3 + CloudFront OACPipelineGitHub Actions OIDCCounterAPI Gateway + Lambda + DynamoDB