~/ emre.cavunt_
GCP

Keyless GCP Secrets in GitHub Actions: Workload Identity, Scoped to One Secret

Drop the service-account JSON key. Federate GitHub Actions into GCP with OIDC, then scope the service account to read one named secret — not the whole project.

A federated identity passing through OIDC gates into a vault where one secret is lit and the rest stay dark
Federate the identity through the gates, then unlock exactly one secret.
TL;DR

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:

  1. GitHub signs an OIDC token for the job (you opt in with permissions: id-token: write).
  2. A workload identity pool provider in GCP accepts tokens from GitHub's issuer — but only if they satisfy an attribute condition you set.
  3. The service account has an IAM binding allowing that specific repo's federated identity to impersonate it.
  4. 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"
  }
}
Warning

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.

Insight

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.

References