Keyless GitHub Actions Deploys to GCP with Workload Identity
Stop pasting service account JSON keys into GitHub Secrets. Workload Identity Federation lets Actions authenticate to GCP with short-lived OIDC tokens.
The usual way to let GitHub Actions deploy to Google Cloud is to create a service account, download its JSON key, and paste that key into a GitHub Secret. It works on the first try, which is exactly why it spreads. It also hands a long-lived credential to a system you do not fully control, with no expiry and no easy audit trail. Workload Identity Federation removes the key entirely. GitHub Actions already issues a signed OIDC token on every run, and you can teach GCP to trust that token for one specific repository and branch. No key is generated, so no key can leak, rotate, or sit forgotten in a secret store. This post shows the full setup, the one attribute condition that makes or breaks the security, and when a plain key is still the faster call.
The problem with a service account key in a secret
A service account key is a bearer credential. Whoever holds the JSON file can act as that service account until someone deletes the key, and by default it never expires. That single fact drives most of the pain.
If the key leaks, through a compromised dependency, a mis-scoped log, a fork with secret access, or a laptop backup, the attacker has your deploy identity for as long as the key lives. You often will not know it happened, because the calls look identical to your CI. Rotation is a manual chore nobody wants: generate a new key, update the secret, confirm nothing broke, delete the old key. Teams skip it, so keys accumulate. Auditing is weak too. A key does not record where it was used from, so you cannot easily prove that only your CI ever called with it.
None of this is a flaw in the key format. A static credential just carries static-credential risk, and CI is a bad place to keep one because the blast radius is your production deploy path.
How Workload Identity Federation works
Workload Identity Federation is federated trust between an external identity provider and GCP. Instead of GCP minting a key that you carry to GitHub, GitHub mints a token that GCP already trusts.
Every GitHub Actions run can request an OIDC token from GitHub's identity provider. That token is a short-lived JWT signed by GitHub, and its claims describe the run: which repository, which branch or tag, which workflow, which environment. GCP hosts a Workload Identity Pool with a provider that names GitHub's issuer as trusted. When your workflow presents the GitHub token, GCP verifies the signature, checks the claims against a condition you set, and if it passes, exchanges the token for a short-lived Google access token that impersonates a service account you chose.
The chain is worth stating plainly. GitHub signs a token that proves the run's identity. GCP trusts GitHub's signature and your condition. The service account grants the actual deploy permissions. No secret crosses the boundary, and the Google token that comes back expires in minutes.
Setting up the pool and provider with gcloud
You create three things once: a pool, an OIDC provider inside it, and a permission binding on the service account. Set your project variables first.
PROJECT_ID="my-project"
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
REPO="my-org/my-repo"
DEPLOY_SA="deploy@${PROJECT_ID}.iam.gserviceaccount.com"
Create the pool, then the provider that trusts GitHub's issuer. The attribute mapping copies claims from the GitHub token into attributes you can reference later.
gcloud iam workload-identity-pools create github-pool \
--project="$PROJECT_ID" \
--location="global" \
--display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-provider \
--project="$PROJECT_ID" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub OIDC" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository == '${REPO}' && assertion.ref == 'refs/heads/main'"
The attribute-condition is the security boundary, and the next section is entirely about it. For now, note that it pins the trust to one repository on its main branch. A token from any other repo or branch fails the exchange before it ever reaches your service account.
Locking the trust to one repo and branch
The single most important line in the whole setup is the attribute condition. Get it too loose and you have not built keyless deploys, you have built a service account that any repository in the world can impersonate.
The failure mode is subtle because the loose version still works for you. If you map attribute.repository but never require a specific value, then any GitHub repository, including a stranger's, can present a valid GitHub-signed token and pass. The signature is genuine. What you skipped was checking whose signature it is. GitHub is happy to issue a correctly signed token to every repo on the platform, so the identity provider's signature alone proves nothing about ownership. Your condition is what ties the trust to you.
Always require the exact repository. Add the branch when only one branch should deploy.
# Good: exactly your repo, main branch only.
--attribute-condition="assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'"
# Dangerous: any repo whose name happens to match a prefix.
--attribute-condition="assertion.repository.startsWith('my-org/')"
# Broken: no ownership check at all, every GitHub repo passes.
--attribute-condition="assertion.repository != ''"
The binding on the service account should match the same scope. Grant the pool the workloadIdentityUser role, but restrict the member to the exact repository attribute so the impersonation right is not pool-wide.
gcloud iam service-accounts add-iam-policy-binding "$DEPLOY_SA" \
--project="$PROJECT_ID" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
Two layers now guard the same door. The provider condition rejects foreign tokens at the exchange, and the member binding limits which identities may impersonate this service account even inside your pool. Keep the service account itself minimal too. If it only needs to deploy to Cloud Run, give it the Cloud Run and Artifact Registry roles it uses and nothing broader. Least privilege on the service account caps the damage if the CI logic is ever tricked into running something unexpected.
The workflow side
On the GitHub side you need one permission and one auth step. The permission id-token: write is what lets the job request an OIDC token at all. Without it the auth step fails with a confusing error, and it is the most common thing people forget.
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOY_SA }}
- uses: google-github-actions/setup-gcloud@v2
- run: |
gcloud run deploy my-service \
--image "$IMAGE" \
--region asia-northeast3
Notice the two inputs come from vars, not secrets. That is the point. Neither value is sensitive. The provider name and the service account email are identifiers, not credentials, so they live as plain repository variables that anyone reviewing the workflow can read.
Set them once from the CLI:
gh variable set GCP_WIF_PROVIDER \
--body "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
gh variable set GCP_DEPLOY_SA --body "$DEPLOY_SA"
The auth action requests the GitHub OIDC token, sends it to the provider, receives a short-lived Google access token, and writes credentials that setup-gcloud and the SDK pick up automatically. Every step after auth is authenticated without a key anywhere in the repo.
The security tradeoffs, side by side
The win is not just tidiness. It changes what an attacker gets from a leak, and what you have to maintain.
| Property | Service account key | Workload Identity Federation |
|---|---|---|
| Credential lifetime | Until manually deleted, no expiry | Minutes, minted per run |
| What a leaked secret gives up | Full SA access, indefinitely | Nothing, there is no stored credential |
| Scope of trust | Anyone holding the file | One repo and branch you name |
| Rotation | Manual, easy to forget | None, tokens are ephemeral |
| Secrets stored in GitHub | The JSON key | None, only public identifiers |
| Audit | Weak, key does not record origin | Token claims record repo, ref, workflow |
The short-lived token is the core of it. A GitHub token that expires in minutes and a Google token that expires soon after are close to worthless once the run ends. There is no durable artifact to steal. The repo and branch scoping means a leak elsewhere in your org, or a malicious fork, cannot borrow this identity, because their token carries a different repository claim and fails the condition. And because you store no key, there is nothing to rotate, which quietly removes an entire recurring task from your operations.
The honest cost is setup complexity. A key is two commands: create, download. Federation is a pool, a provider with a carefully worded condition, a member binding, and two workflow inputs. The condition in particular is easy to get subtly wrong in the loose direction, and a loose condition looks like it works. Budget time to write it precisely and to test that a token from a different branch actually gets rejected. After that first setup, though, maintenance is close to zero, because there is no key to babysit.
When a service account key is still the easy choice
Federation is the right default for CI that deploys to production, but it is not free, and a few cases genuinely favor a key.
If the caller is not an OIDC-capable identity provider, federation has nothing to trust. A cron job on a random box, a developer laptop script, or a legacy system with no token issuer cannot present the signed assertion the exchange needs. Bridging those into a workload identity pool is often more work than it saves, and a scoped, short-rotation key can be the pragmatic answer.
A throwaway sandbox project with no real data and a lifespan of days is another case. The blast radius of a leaked key is small when the whole project will be deleted next week, and the setup tax buys little. Local development against an emulator needs no cloud credential at all, so neither approach applies.
The line is roughly this. If the workload runs somewhere that issues OIDC tokens, GitHub Actions, GitLab CI, and most modern platforms do, prefer federation and delete the keys. If it runs somewhere without a trustworthy token issuer, or the project is disposable, a tightly scoped key with a short rotation schedule is a defensible choice. For the common case of GitHub Actions deploying to GCP, though, the key buys you a few minutes today and a standing liability for as long as it exists. Federation costs a longer afternoon once and then stays out of your way.
Related posts
Three Layers That Keep Cloud Run Traffic Behind Cloudflare
A public run.app URL lets anyone bypass your CDN and hit Cloud Run directly. Three layers close that gap: ingress limits, load balancer, secret header.
Cloud Run Service vs Job: One Go Binary, Two Entry Points
A Cloud Run Service and Job can share one Go image and one build. Here is how a single binary splits into a serve mode and batch jobs, and when not to.
How to Rotate JWT Signing Keys Without Logging Everyone Out
Rotating a JWT signing key naively invalidates every live token and logs out all users. Here is the overlap window and kid design that avoids it.