Workload Identity Federation

Cloud & Infrastructure advanced Building with AI ~25 min read

Overview

Workload Identity Federation is a GCP mechanism that eliminates long-lived service account keys by allowing external identities—such as Kubernetes ServiceAccounts, GitHub Actions OIDC tokens, or AWS IAM roles—to impersonate GCP service accounts through short-lived token exchange. Instead of distributing JSON key files that can be leaked, rotated incorrectly, or exfiltrated, workloads authenticate using their native identity provider and exchange that credential for a scoped, time-limited GCP access token via the Security Token Service (STS).

For Kubernetes workloads running on GKE, GKE Workload Identity is a specialized form of this federation. A Kubernetes ServiceAccount (KSA) is annotated with a GCP Service Account (GSA) email, and the GKE metadata server intercepts instance metadata requests from pods, returning short-lived tokens for the bound GSA instead of the node’s default service account. This eliminates the node-level blast radius—compromising one pod no longer grants access to the node’s broad permissions.

As a practical example, an infrastructure codebase can provision Workload Identity bindings for every service that needs GCP API access: the search API pods get read access to Cloud Storage and BigQuery, background workers get Pub/Sub publisher permissions, and the evaluation pipeline gets Vertex AI access. These bindings satisfy SOC2 controls around least-privilege access and credential lifecycle management. Understanding this pattern is essential for adding new services or debugging authentication failures in production.

Core Concepts

OIDC Token Exchange via Security Token Service

At the heart of Workload Identity Federation is OIDC-based token exchange. The workload presents a JWT signed by its identity provider (GKE metadata server, GitHub Actions, etc.) to Google’s Security Token Service (STS). The STS validates the token against the configured Workload Identity Pool, checks the attribute mapping and conditions, and issues a federated access token. This token is then used to call generateAccessToken on a GCP service account via the IAM Credentials API, producing a short-lived OAuth2 token (default 1-hour TTL) that the workload uses to call GCP APIs.

Workload Identity Pool and Provider

A Workload Identity Pool is a container for external identity providers within a GCP project. Each pool has one or more providers (e.g., an OIDC provider for GKE, another for GitHub Actions). The pool defines attribute mappings that extract claims from the external token (e.g., google.subject = assertion.sub) and optional attribute conditions that restrict which tokens are accepted (e.g., only tokens from a specific repository or namespace). This layered filtering prevents unauthorized workloads from impersonating GCP service accounts even if they share the same identity provider.

GKE Workload Identity Binding

On GKE, Workload Identity works through a three-way binding: (1) the Kubernetes ServiceAccount is annotated with iam.gke.io/gcp-service-account: GSA_EMAIL, (2) the GCP Service Account has an IAM policy binding granting roles/iam.workloadIdentityUser to serviceAccount:PROJECT.svc.id.goog[NAMESPACE/KSA_NAME], and (3) the GKE metadata server intercepts token requests from pods running under that KSA. All three must be correct—missing any one results in silent authentication failures where pods fall back to the node’s default SA or get 403 errors.

Least-Privilege IAM Binding

Each GCP Service Account should have only the IAM roles required by its specific workload. Workload Identity makes this practical because each microservice can have its own GSA with scoped permissions, rather than sharing a single node-level service account. In a production IaC setup, the search API’s GSA has roles/storage.objectViewer and roles/bigquery.dataViewer, while the ingestion pipeline’s GSA has roles/pubsub.publisher and roles/storage.objectCreator—neither can escalate to the other’s permissions.

Credential Lifecycle: No Keys to Rotate

Traditional service account keys are long-lived (no expiry by default), must be manually rotated, and create incident risk when leaked. Workload Identity tokens are issued on-demand with a configurable TTL (typically 1 hour) and are never persisted to disk. There is no key file to leak, no rotation schedule to maintain, and no risk of stale credentials in CI/CD pipelines. Audit logs show which identity exchanged tokens and when, creating a clear trail for compliance without the operational burden of key management.

How It Works

Implementing Workload Identity Federation for GKE involves four phases: enabling the Workload Identity pool on the cluster, creating purpose-specific GCP service accounts, establishing the IAM binding between Kubernetes and GCP identities, and annotating Kubernetes ServiceAccounts to trigger the metadata server interception.

Enabling Workload Identity on the GKE Cluster

When provisioning a GKE cluster, the workloadIdentityConfig field activates the Workload Identity metadata server on every node. This must be set at cluster creation time (or via a cluster update that restarts nodes). The workload pool name follows the convention PROJECT_ID.svc.id.goog.

package infra

import (
    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/container"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func NewWorkloadIdentityCluster(ctx *pulumi.Context, env string, project string) (*container.Cluster, error) {
    cluster, err := container.NewCluster(ctx, env+"-cluster", &container.ClusterArgs{
        Name:            pulumi.String(env + "-cluster"),
        Location:        pulumi.String("us-central1"),
        Project:         pulumi.String(project),
        EnableAutopilot: pulumi.Bool(true),

        WorkloadIdentityConfig: &container.ClusterWorkloadIdentityConfigArgs{
            WorkloadPool: pulumi.String(project + ".svc.id.goog"),
        },
    })
    if err != nil {
        return nil, err
    }
    return cluster, nil
}

Creating Purpose-Specific GCP Service Accounts

Each workload that needs GCP API access gets its own GSA with scoped IAM roles. The infrastructure project creates these as part of the infrastructure stack rather than manually, ensuring they are version-controlled and reproducible across environments.

package infra

import (
    "fmt"

    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/serviceaccount"
    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/projects"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type WorkloadSA struct {
    Name  string
    Roles []string // e.g. "roles/storage.objectViewer"
}

func NewWorkloadServiceAccounts(ctx *pulumi.Context, project string, workloads []WorkloadSA) error {
    for _, w := range workloads {
        sa, err := serviceaccount.NewAccount(ctx, w.Name+"-sa", &serviceaccount.AccountArgs{
            AccountId:   pulumi.String(w.Name),
            DisplayName: pulumi.String(fmt.Sprintf("Workload SA for %s", w.Name)),
            Project:     pulumi.String(project),
        })
        if err != nil {
            return err
        }

        for i, role := range w.Roles {
            _, err := projects.NewIAMMember(ctx,
                fmt.Sprintf("%s-role-%d", w.Name, i),
                &projects.IAMMemberArgs{
                    Project: pulumi.String(project),
                    Role:    pulumi.String(role),
                    Member:  sa.Email.ApplyT(func(email string) string {
                        return "serviceAccount:" + email
                    }).(pulumi.StringOutput),
                },
            )
            if err != nil {
                return err
            }
        }
    }
    return nil
}

Establishing the IAM Binding

The critical step: granting the Kubernetes ServiceAccount permission to impersonate the GCP Service Account. This is an IAM policy binding on the GSA that grants roles/iam.workloadIdentityUser to the KSA principal. The principal format encodes both the Kubernetes namespace and ServiceAccount name, so a KSA in namespace search cannot impersonate a GSA bound to namespace admin.

package infra

import (
    "fmt"

    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/serviceaccount"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type WIBinding struct {
    GSAName   string // GCP service account name
    Namespace string // Kubernetes namespace
    KSAName   string // Kubernetes ServiceAccount name
}

func BindWorkloadIdentity(ctx *pulumi.Context, project string, bindings []WIBinding) error {
    for _, b := range bindings {
        member := fmt.Sprintf(
            "serviceAccount:%s.svc.id.goog[%s/%s]",
            project, b.Namespace, b.KSAName,
        )

        _, err := serviceaccount.NewIAMBinding(ctx,
            fmt.Sprintf("wi-%s-%s", b.Namespace, b.KSAName),
            &serviceaccount.IAMBindingArgs{
                ServiceAccountId: pulumi.Sprintf(
                    "projects/%s/serviceAccounts/%s@%s.iam.gserviceaccount.com",
                    project, b.GSAName, project,
                ),
                Role:    pulumi.String("roles/iam.workloadIdentityUser"),
                Members: pulumi.StringArray{pulumi.String(member)},
            },
        )
        if err != nil {
            return err
        }
    }
    return nil
}

Annotating Kubernetes ServiceAccounts

The final step is creating or patching Kubernetes ServiceAccounts with the iam.gke.io/gcp-service-account annotation. When a pod runs under this KSA, the GKE metadata server intercepts calls to the instance metadata endpoint (169.254.169.254) and returns tokens for the bound GSA instead of the node’s default SA.

package k8s

import (
    "fmt"

    corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
    metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func NewAnnotatedServiceAccount(ctx *pulumi.Context, namespace, ksaName, gsaEmail string) error {
    _, err := corev1.NewServiceAccount(ctx,
        fmt.Sprintf("%s-%s-ksa", namespace, ksaName),
        &corev1.ServiceAccountArgs{
            Metadata: &metav1.ObjectMetaArgs{
                Name:      pulumi.String(ksaName),
                Namespace: pulumi.String(namespace),
                Annotations: pulumi.StringMap{
                    "iam.gke.io/gcp-service-account": pulumi.String(gsaEmail),
                },
            },
        },
    )
    return err
}

Implementation Patterns

Per-Service Identity Isolation

The infrastructure project follows a strict one-GSA-per-service pattern. Each microservice—search API, ingestion pipeline, evaluation runner, billing webhook—gets its own GCP Service Account with only the IAM roles it needs. This prevents lateral movement: if the search API is compromised, the attacker can read Cloud Storage objects but cannot publish to Pub/Sub or access Vertex AI. The binding matrix is declared as a Go data structure in the infrastructure code, making it auditable in code review.

var workloadBindings = []struct {
    Service   string
    Namespace string
    Roles     []string
}{
    {"search-api", "search", []string{"roles/storage.objectViewer", "roles/bigquery.dataViewer"}},
    {"ingestion", "pipeline", []string{"roles/pubsub.publisher", "roles/storage.objectCreator"}},
    {"eval-runner", "evaluation", []string{"roles/aiplatform.user"}},
    {"billing-hook", "billing", []string{"roles/monitoring.metricWriter"}},
}

Cross-Project Federation for CI/CD

For GitHub Actions workflows that deploy to GCP, the infrastructure project configures a Workload Identity Pool with a GitHub OIDC provider. The attribute condition restricts token exchange to the specific repository and branch, preventing other repositories in the same organization from impersonating the deployment identity.

package infra

import (
    "github.com/pulumi/pulumi-gcp/sdk/v7/go/gcp/iam"
    "github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func NewGitHubActionsPool(ctx *pulumi.Context, project, repo string) error {
    pool, err := iam.NewWorkloadIdentityPool(ctx, "github-pool", &iam.WorkloadIdentityPoolArgs{
        WorkloadIdentityPoolId: pulumi.String("github-actions"),
        Project:                pulumi.String(project),
        DisplayName:            pulumi.String("GitHub Actions"),
    })
    if err != nil {
        return err
    }

    _, err = iam.NewWorkloadIdentityPoolProvider(ctx, "github-provider",
        &iam.WorkloadIdentityPoolProviderArgs{
            WorkloadIdentityPoolId:         pool.WorkloadIdentityPoolId,
            WorkloadIdentityPoolProviderId: pulumi.String("github-oidc"),
            Project:                        pulumi.String(project),
            DisplayName:                    pulumi.String("GitHub OIDC"),
            AttributeMapping: pulumi.StringMap{
                "google.subject":       pulumi.String("assertion.sub"),
                "attribute.actor":      pulumi.String("assertion.actor"),
                "attribute.repository": pulumi.String("assertion.repository"),
            },
            AttributeCondition: pulumi.Sprintf(
                "assertion.repository == '%s'", repo,
            ),
            Oidc: &iam.WorkloadIdentityPoolProviderOidcArgs{
                IssuerUri: pulumi.String("https://token.actions.githubusercontent.com"),
            },
        },
    )
    return err
}

Debugging Authentication Failures

Workload Identity failures are notoriously silent—pods either get 403 errors or fall back to node credentials without clear error messages. A production IaC setup includes a diagnostic script that verifies all three legs of the binding: the KSA annotation, the IAM policy on the GSA, and the metadata server response from inside a pod.

#!/bin/bash
# Verify Workload Identity binding for a given KSA

NAMESPACE=$1
KSA=$2

echo "=== Step 1: Check KSA annotation ==="
kubectl get serviceaccount "$KSA" -n "$NAMESPACE" \
  -o jsonpath='{.metadata.annotations.iam\.gke\.io/gcp-service-account}'
echo

echo "=== Step 2: Check IAM binding on GSA ==="
GSA=$(kubectl get serviceaccount "$KSA" -n "$NAMESPACE" \
  -o jsonpath='{.metadata.annotations.iam\.gke\.io/gcp-service-account}')
gcloud iam service-accounts get-iam-policy "$GSA" \
  --format='table(bindings.role, bindings.members)'

echo "=== Step 3: Test token from inside a pod ==="
kubectl run wi-test --image=google/cloud-sdk:slim \
  --serviceaccount="$KSA" -n "$NAMESPACE" --rm -it --restart=Never -- \
  gcloud auth print-identity-token --audiences=https://example.com 2>&1

Practical Applications

In a cloud-native microservices architecture, Workload Identity Federation can be used across every service boundary that touches GCP APIs:

Common Pitfalls

Further Reading