
Stop pasting service-account JSON keys into GitHub secrets. Federate GitHub
Actions into GCP with Workload Identity Federation — short-lived tokens, no key
on disk — and then do the half everyone skips: grant
roles/secretmanager.secretAccessor on the specific secret, not the
project. The workflow ends up able to read exactly one secret and nothing else.
The credential you forgot you committed
Here is the setup almost every team starts with. A GitHub Action needs to read a
secret from GCP, so someone runs gcloud iam service-accounts keys create,
downloads the JSON, and pastes it into a repository secret called
GCP_SA_KEY. It works on the first try, the deploy goes green, everyone moves
on.
That key never expires. Nobody rotates it. It sits in your CI provider's
database and in the memory of every job that decodes it, one echo away from a
build log. And because granting IAM is tedious, that same service account was
almost certainly given roles/secretmanager.secretAccessor at the project
level — so the key that just leaked can read every secret you have.
Two problems, and they compound: a long-lived credential, attached to an over-broad identity. Workload Identity Federation fixes the first. Resource-level IAM fixes the second. You want both, and they're about an afternoon's work.
This post is the GCP companion to my
GKE-to-AWS identity federation piece —
same idea, different clouds. Everything here is in a runnable repo:
nextjs-cloudflare-static-starter
(the gcp-wif/ module), where it's wired into a real Cloudflare deploy.
How federation works
There's no GCP key because GitHub already issues a perfectly good identity. On every run, GitHub will mint a signed OIDC token describing the job: which repo, which ref, which workflow. Workload Identity Federation teaches GCP to trust that token and exchange it, through GCP's Security Token Service, for a short-lived access token that impersonates a service account.
The chain has four links, and each one is a gate you control:
- GitHub signs an OIDC token for the job (you opt in with
permissions: id-token: write). - A workload identity pool provider in GCP accepts tokens from GitHub's issuer — but only if they satisfy an attribute condition you set.
- The service account has an IAM binding allowing that specific repo's federated identity to impersonate it.
- The service account holds resource-level permissions — here, read access to named secrets.
Nothing durable is stored anywhere. The token lives for minutes, and it only exists inside the job that was authorised to mint it.
Setting it up with Terraform
The whole thing is declarative. I'll go through it in the order the trust flows.
The pool and the GitHub provider
The pool is a container for non-GCP identities. The provider inside it is what actually trusts GitHub:
resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-actions"
display_name = "GitHub Actions"
description = "Federated identities from GitHub Actions OIDC."
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
display_name = "GitHub OIDC"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.repository_owner" = "assertion.repository_owner"
}
# CRITICAL: without this condition, ANY GitHub repository in the world could
# exchange a token against this provider. Pin it to your org/user.
attribute_condition = "assertion.repository_owner == '${var.github_owner}'"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}resource "google_iam_workload_identity_pool" "github" {
workload_identity_pool_id = "github-actions"
display_name = "GitHub Actions"
description = "Federated identities from GitHub Actions OIDC."
}
resource "google_iam_workload_identity_pool_provider" "github" {
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-oidc"
display_name = "GitHub OIDC"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.repository_owner" = "assertion.repository_owner"
}
# CRITICAL: without this condition, ANY GitHub repository in the world could
# exchange a token against this provider. Pin it to your org/user.
attribute_condition = "assertion.repository_owner == '${var.github_owner}'"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}The attribute_condition is not optional, even though Terraform will happily
apply without it. A provider with no condition trusts GitHub's issuer — all
of it. That means any repository on GitHub, including one an attacker creates
this afternoon, can present a valid token to your provider. The condition is
what makes it your provider. Pin it to repository_owner at minimum.
The service account and who may impersonate it
The service account has no key — it never will. The binding below is the second
gate: it says only the federated identity for one specific repository may
impersonate it. Note the principalSet scoped to attribute.repository, not
the whole pool:
resource "google_service_account" "deployer" {
account_id = var.service_account_id
display_name = "GitHub Actions deployer (WIF)"
}
resource "google_service_account_iam_member" "wif_user" {
service_account_id = google_service_account.deployer.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_owner}/${var.github_repo}"
}resource "google_service_account" "deployer" {
account_id = var.service_account_id
display_name = "GitHub Actions deployer (WIF)"
}
resource "google_service_account_iam_member" "wif_user" {
service_account_id = google_service_account.deployer.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_owner}/${var.github_repo}"
}Two gates, deliberately. The provider condition decides who may exchange a token; this binding decides who may impersonate the account. Other repos in your org pass the first gate and still fail the second.
The half everyone skips: scope the secret
This is the part worth slowing down for. You create the secrets as containers, then grant access per secret:
resource "google_secret_manager_secret" "approved" {
for_each = toset(var.approved_secrets)
secret_id = each.value
replication {
auto {}
}
}
resource "google_secret_manager_secret_iam_member" "accessor" {
for_each = google_secret_manager_secret.approved
secret_id = each.value.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.deployer.email}"
}resource "google_secret_manager_secret" "approved" {
for_each = toset(var.approved_secrets)
secret_id = each.value
replication {
auto {}
}
}
resource "google_secret_manager_secret_iam_member" "accessor" {
for_each = google_secret_manager_secret.approved
secret_id = each.value.secret_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.deployer.email}"
}The binding is google_secret_manager_secret_iam_member — IAM attached to a
named secret resource. Compare it to the line you'll see in most tutorials:
# DON'T: this grants read access to EVERY secret in the project.
resource "google_project_iam_member" "accessor" {
project = var.project_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.deployer.email}"
}# DON'T: this grants read access to EVERY secret in the project.
resource "google_project_iam_member" "accessor" {
project = var.project_id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.deployer.email}"
}They look almost identical. The difference is the blast radius. With the
project-level grant, a workflow that only needed your Cloudflare token can read
your database password, your Stripe key, and the secret you forgot was in there.
With the resource-level grant, the service account can read exactly the secrets
in approved_secrets and is told "permission denied" for anything else.
Least privilege isn't a posture, it's a default you pick once. Adding a new
secret to the allowlist is a one-line change to approved_secrets and a code
review. That tiny bit of friction is the feature: a new secret is unreadable
until someone deliberately grants it, in a diff, with your name on the approval.
The workflow
Two terraform output values wire it together —
workload_identity_provider and service_account. The job declares
id-token: write, authenticates with no key, and pulls one named secret:
name: Read a GCP secret (keyless)
on:
workflow_dispatch:
permissions:
contents: read
id-token: write # required: lets GitHub mint the OIDC token for WIF
jobs:
read-secret:
runs-on: ubuntu-latest
steps:
- name: Authenticate to GCP (no key)
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
- name: Fetch the approved secret
id: secrets
uses: google-github-actions/get-secretmanager-secrets@v3
with:
secrets: |-
cloudflare_token:${{ vars.GCP_PROJECT_ID }}/cloudflare-api-token
- name: Use it
env:
CLOUDFLARE_API_TOKEN: ${{ steps.secrets.outputs.cloudflare_token }}
run: echo "Fetched a ${#CLOUDFLARE_API_TOKEN}-char token without storing a key."name: Read a GCP secret (keyless)
on:
workflow_dispatch:
permissions:
contents: read
id-token: write # required: lets GitHub mint the OIDC token for WIF
jobs:
read-secret:
runs-on: ubuntu-latest
steps:
- name: Authenticate to GCP (no key)
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
- name: Fetch the approved secret
id: secrets
uses: google-github-actions/get-secretmanager-secrets@v3
with:
secrets: |-
cloudflare_token:${{ vars.GCP_PROJECT_ID }}/cloudflare-api-token
- name: Use it
env:
CLOUDFLARE_API_TOKEN: ${{ steps.secrets.outputs.cloudflare_token }}
run: echo "Fetched a ${#CLOUDFLARE_API_TOKEN}-char token without storing a key."There is no credentials_json, no GCP_SA_KEY, no base64 blob. The provider and
service-account identifiers aren't even secret — they're stored as Actions
variables, not secrets, because on their own they grant nothing. The
authorisation lives in GCP's IAM, where it belongs, not in your CI provider's
secret store.
Prove the boundary holds
Don't take my word that the scoping works — make GCP tell you. After
terraform apply, impersonate the service account and try to read a secret you
did not grant:
# Reads fine — it's on the allowlist.
gcloud secrets versions access latest \
--secret=cloudflare-api-token \
--impersonate-service-account="$SA_EMAIL"
# Denied — it isn't.
gcloud secrets versions access latest \
--secret=some-other-secret \
--impersonate-service-account="$SA_EMAIL"
# ERROR: (gcloud.secrets.versions.access) PERMISSION_DENIED:
# Permission 'secretmanager.versions.access' denied for resource
# 'projects/.../secrets/some-other-secret/versions/latest'# Reads fine — it's on the allowlist.
gcloud secrets versions access latest \
--secret=cloudflare-api-token \
--impersonate-service-account="$SA_EMAIL"
# Denied — it isn't.
gcloud secrets versions access latest \
--secret=some-other-secret \
--impersonate-service-account="$SA_EMAIL"
# ERROR: (gcloud.secrets.versions.access) PERMISSION_DENIED:
# Permission 'secretmanager.versions.access' denied for resource
# 'projects/.../secrets/some-other-secret/versions/latest'That second command failing is the whole point of the design. If it succeeds, you've got a project-level grant hiding somewhere — go find it.
Pitfalls I've watched people hit
The wide-open provider
By far the most common, and the most dangerous: a provider with no
attribute_condition. It will work perfectly in your repo, which is exactly why
it survives review. It also works in everyone else's. Always pin to your owner;
pin to the specific repo if the pool only ever serves one.
Forgetting id-token: write
Without it, GitHub won't mint the OIDC token and auth fails with a confusing
"unable to get the ID token" error. It's a per-job permission and it's easy to
lose when you refactor a workflow.
Granting the role on the project "just for now"
"Just for now" is how every over-privileged service account in the world was
born. If you find yourself reaching for google_project_iam_member with
secretAccessor, stop and list the secrets the job actually needs. It's usually
one.
Reusing one almighty service account
One service account per workflow's actual need beats one shared account that can do everything. They're free. The blast radius of a compromised job should be the permissions of that job, not of your whole pipeline.
Where this pays off
The companion to this post, serving a Next.js static site on Cloudflare, uses exactly this to deploy: instead of storing a Cloudflare API token as a GitHub secret, the deploy workflow authenticates to GCP via WIF and pulls the token from Secret Manager at run time. The token never lives in GitHub at all. That's the pattern generalised — your CI holds no long-lived credentials, GCP holds the secrets, and access is scoped to the one job that needs it.
Conclusion
Keyless auth and least-privilege IAM are usually discussed as two separate chores. They're the same job, done at the same time, in the same Terraform: stop storing a credential that outlives the need for it, and stop handing out access beyond the need for it. Federate the identity, scope it to the secret, and prove the boundary with a command that's supposed to fail. An afternoon now against a very bad week later.