Eliminate GCP Service Account Keys with Workload Identity
Eliminating GCP Service Account Keys with Workload Identity Federation
Static service account keys remain the single biggest identity risk in GCP environments. I find them in production across most new client engagements—embedded in CI/CD environment variables, committed to code repositories, sitting on developer laptops. Each one is a credential that never expires until someone remembers to rotate it.
Workload Identity Federation eliminates keys entirely by letting external identities exchange OIDC tokens for short-lived GCP credentials. This guide walks through implementing WIF for GitHub Actions and GKE workloads, plus service account impersonation for developers—the complete pattern for keyless GCP operations.
Why This Matters
A compromised GCP API key cost one team I advised $450k in a single weekend. Someone pushed a key to a public GitHub repo. Attackers spun up Cloud Run services in every region within hours. The key had no expiry, no geographic restrictions, and far more permissions than the workload actually needed.
WIF makes this attack vector impossible. No static credential exists to steal.
Prerequisites
Before starting:
- GCP project with billing enabled
gcloudCLI authenticated with permissions to create IAM resources- For GitHub Actions: repository where you control workflow files
- For GKE: existing cluster with Workload Identity enabled (or ability to enable it)
- Organization-level access if you want to enforce the
disableServiceAccountKeyCreationconstraint
Step 1: Create a Dedicated Service Account
Why this matters: The service account defines your workload's permission boundary. Every external identity (GitHub Actions runner, Kubernetes pod) will ultimately operate with this SA's permissions.
gcloud iam service-accounts create github-actions-deploy \
--display-name="GitHub Actions Deployment SA" \
--project=my-project-id
Grant only the permissions the workload actually needs:
gcloud projects add-iam-policy-binding my-project-id \
--member="serviceAccount:github-actions-deploy@my-project-id.iam.gserviceaccount.com" \
--role="roles/run.developer"
What goes wrong: Teams often copy permissions from an existing over-privileged SA. Start with zero permissions and add only what's required. I've seen service accounts with roles/owner being federated—the WIF pattern is secure, but it can't fix bad permission boundaries.
Step 2: Create a Workload Identity Pool
Why this matters: The pool is a container for identity providers. You'll have one pool with potentially multiple providers (GitHub, GitLab, AWS, etc.).
gcloud iam workload-identity-pools create github-pool \
--location="global" \
--display-name="GitHub Actions Pool" \
--project=my-project-id
What goes wrong: Teams create separate pools per repository. This creates management overhead. One pool per identity provider type is usually sufficient—use attribute conditions to scope access per repository.
Step 3: Add GitHub as an Identity Provider
Why this matters: This tells GCP how to validate tokens from GitHub Actions and what claims to trust.
gcloud iam workload-identity-pools providers create-oidc github-provider \
--location="global" \
--workload-identity-pool="github-pool" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.actor=assertion.actor" \
--attribute-condition="assertion.repository=='my-org/my-repo'" \
--project=my-project-id
The --attribute-condition is critical. Without it, any GitHub Actions workflow from any repository could impersonate your service account.
What goes wrong: The attribute condition syntax trips people up. Use single quotes inside the condition string. Test with a permissive condition first, then tighten it once tokens are flowing.
Step 4: Bind the Service Account to the Workload Identity
Why this matters: This is the authorization step—allowing the federated identity to impersonate the GCP service account.
gcloud iam service-accounts add-iam-policy-binding \
github-actions-deploy@my-project-id.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo" \
--project=my-project-id
Replace PROJECT_NUMBER with your numeric project number (not the project ID).
What goes wrong: Using project ID instead of project number is the most common error. The binding silently succeeds but authentication fails. Find your project number with:
gcloud projects describe my-project-id --format="value(projectNumber)"
Step 5: Configure GitHub Actions Workflow
Why this matters: The workflow authenticates to GCP without any stored secrets.
name: Deploy to Cloud Run
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: github-actions-deploy@my-project-id.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud run deploy my-service --source .
What goes wrong: Missing permissions.id-token: write causes silent failures. The auth action can't request the OIDC token without this permission explicitly granted.
Step 6: Configure GKE Workload Identity
For Kubernetes workloads, the pattern connects a Kubernetes Service Account to a GCP Service Account.
Why this matters: Pods authenticate to GCP APIs without mounting key files or setting environment variables.
# Create the GCP service account
gcloud iam service-accounts create gke-workload-sa \
--project=my-project-id
# Bind the Kubernetes SA to the GCP SA
gcloud iam service-accounts add-iam-policy-binding \
gke-workload-sa@my-project-id.iam.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:my-project-id.svc.id.goog[my-namespace/my-ksa]"
Annotate the Kubernetes Service Account:
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-ksa
namespace: my-namespace
annotations:
iam.gke.io/gcp-service-account: gke-workload-sa@my-project-id.iam.gserviceaccount.com
What goes wrong: The namespace must match exactly. I've seen pods fail silently with permission denied because the KSA was in default but the binding specified production. Check the binding member string character by character.
Step 7: Enable Service Account Impersonation for Developers
Why this matters: Developers need GCP access for local development without holding permanent keys.
# Grant impersonation permission to a developer
gcloud iam service-accounts add-iam-policy-binding \
dev-sa@my-project-id.iam.gserviceaccount.com \
--role="roles/iam.serviceAccountTokenCreator" \
--member="user:developer@company.com"
Developers configure impersonation:
gcloud config set auth/impersonate_service_account dev-sa@my-project-id.iam.gserviceaccount.com
What goes wrong: Audit trails only capture impersonation events if Cloud Audit Logs include sts.googleapis.com data access logs. Enable these explicitly or you'll have gaps in your audit trail during SOC 2 reviews.
Step 8: Enforce Keyless Operations with Org Policy
Why this matters: This is the Security-by-Design principle from the SCALE framework—making insecure patterns impossible rather than relying on policy compliance.
gcloud org-policies set-policy policy.yaml --project=my-project-id
Where policy.yaml contains:
name: projects/my-project-id/policies/iam.disableServiceAccountKeyCreation
spec:
rules:
- enforce: true
What goes wrong: Applying this policy without WIF already working causes immediate CI/CD failures. Migrate all workloads first, verify they're functioning, then enable the constraint.
Common Mistakes
Leaving old keys active during migration. I've seen teams complete WIF setup but keep old keys "just in case." Audit trails show both being used months later. Delete old keys once WIF is verified working.
Cross-project impersonation chains. Keep impersonation to one hop maximum. Service account A impersonating B impersonating C creates debugging nightmares and audit complexity.
Over-scoped attribute conditions. An empty --attribute-condition lets any token from that provider authenticate. Always scope to specific repositories, branches, or environments.
Every SOC 2 audit I've supported has flagged service account keys as a finding. The migration path from keys to WIF is well-defined—the blocker is usually organizational, not technical.
Work with a GCP specialist — book a free discovery call
Amit Malhotra, Principal GCP Architect, Buoyant Cloud Inc
Work with a GCP specialist — book a free discovery call → https://buoyantcloudtech.com