Workload Identity Federation from AWS to Google Cloud — without service account keys

Let EC2, Lambda, ECS and EKS workloads call Google Cloud with short-lived tokens: how the token exchange works, step-by-step setup, the EKS trap, and a hub-and-spoke design for many accounts.

Đọc bằng tiếng Việt →

A service running on AWS needs to read a BigQuery dataset or write to a Cloud Storage bucket. The fastest way is also the worst one: create a Google service account, download key.json, store it in AWS Secrets Manager, and mount it into the app. That key never expires. It gets copied into CI and laptops, and nobody rotates it. Leaked service account keys are one of the most common ways GCP projects get compromised.

Workload Identity Federation (WIF) removes the key entirely. The AWS workload proves who it is using the identity it already has, its IAM role. Google exchanges that proof for a short-lived Google token. Nothing secret is stored, shipped or rotated.

This is the cross-cloud sibling of EKS Pod Identity vs IRSA. There the question was how a pod gets AWS credentials. Here it is how an AWS workload gets Google credentials.

How the token exchange works

AWS workload sends a signed GetCallerIdentity request to Google STS, which verifies it with AWS STS, applies the pool mapping and returns a federated token
The AWS secret key never leaves AWS. Google only receives a signed request that it can replay to AWS STS once to learn the caller's ARN.
  1. The Google client library takes the workload’s AWS credentials and signs (SigV4) a request to AWS STS GetCallerIdentity, but does not send it to AWS.
  2. It sends that signed request to Google STS (sts.googleapis.com) as the “subject token”. Google STS replays it to AWS STS, and AWS answers with the caller’s ARN, for example arn:aws:sts::444444444444:assumed-role/orders-reporter/i-0abc….
  3. Google checks the request against your workload identity pool and provider: is this AWS account trusted, and does the attribute condition allow this role? It then maps the ARN to Google attributes such as aws_role.
  4. Google STS returns a federated access token, valid for about an hour.
  5. The workload calls Google APIs with that token (direct resource access), or first exchanges it for a service account token (impersonation) for APIs that don’t accept federated identities yet.

Google’s recommendation is to grant access directly to the federated principal whenever the product supports it, and use service account impersonation only where it doesn’t.

Two kinds of provider

AWS provider OIDC provider (EKS issuer)
Identity Google sees IAM role ARN Kubernetes service account (system:serviceaccount:ns:name)
Subject token Signed GetCallerIdentity Projected service account JWT
Works out of the box EC2 (instance metadata), Lambda (env vars) EKS pods (token mounted as a file)
Needs code ECS tasks, EKS pods (custom credential supplier) —
Granularity per IAM role per namespace / service account

The two are not mutually exclusive. A single pool can contain one AWS provider per account plus one OIDC provider per EKS cluster.

Path A: EC2 and Lambda with an AWS provider

1. Create the pool and the provider

Use a dedicated project for identity (here platform-identity, project number 123456789012) and one pool per trust boundary, for example production:

gcloud iam workload-identity-pools create aws-prod \
  --project=platform-identity \
  --location=global \
  --display-name="AWS production"

gcloud iam workload-identity-pools providers create-aws aws-prod \
  --project=platform-identity \
  --location=global \
  --workload-identity-pool=aws-prod \
  --account-id=444444444444 \
  --attribute-mapping="google.subject=assertion.account+':'+assertion.arn.extract('assumed-role/{role_name}/'),attribute.account=assertion.account,attribute.aws_role=assertion.arn.extract('assumed-role/{role_name}/')" \
  --attribute-condition="assertion.arn.startsWith('arn:aws:sts::444444444444:assumed-role/')"
  • google.subject is account:role, for example 444444444444:orders-reporter. It shows up in Cloud Audit Logs and is at most 77 bytes. Google’s own examples map the full ARN instead, which also records the session, but a long role name plus a long session name breaks the 127-byte limit. See the 127-byte subject limit below.
  • attribute.aws_role extracts the role name (orders-reporter). That is what you grant permissions to.
  • The attribute condition only accepts assumed roles from this account. IAM users and the root user are rejected. Always set a condition.

2. Grant access

Direct resource access, preferred. The principal is “any session of role orders-reporter coming through pool aws-prod”:

gcloud storage buckets add-iam-policy-binding gs://analytics-prod-reports \
  --role=roles/storage.objectViewer \
  --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/aws-prod/attribute.aws_role/orders-reporter"

Service account impersonation, for products that need it:

gcloud iam service-accounts add-iam-policy-binding \
  orders-reporter@analytics-prod.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/aws-prod/attribute.aws_role/orders-reporter"

3. Generate the credential configuration

gcloud iam workload-identity-pools create-cred-config \
  projects/123456789012/locations/global/workloadIdentityPools/aws-prod/providers/aws-prod \
  --aws \
  --enable-imdsv2 \
  --output-file=gcp-wif.json
# add --service-account=orders-reporter@analytics-prod.iam.gserviceaccount.com for impersonation

The result contains no secrets, only instructions telling the library where to get AWS credentials and where to exchange them:

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/aws-prod/providers/aws-prod",
  "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": {
    "environment_id": "aws1",
    "region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
    "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
    "regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
    "imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
  }
}

You can commit this file, bake it into an image or put it in a ConfigMap. Point the app at it, and every Google client library picks it up through Application Default Credentials:

export GOOGLE_APPLICATION_CREDENTIALS=/etc/gcp/gcp-wif.json

# quick test from the instance
gcloud auth login --cred-file=/etc/gcp/gcp-wif.json
gcloud storage ls gs://analytics-prod-reports

On EC2, the library reads the role credentials from instance metadata (IMDSv2 with --enable-imdsv2). On Lambda there is no metadata service. The library falls back to AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN and AWS_REGION, which Lambda sets for the function’s role automatically.

The EKS (and ECS) trap

This is where most first attempts go wrong. The --aws credential configuration only knows two sources: instance metadata and environment variables. A pod on EKS gets its AWS credentials from Pod Identity (a container credentials endpoint) or IRSA (a web identity token file). An ECS task gets them from the task-role endpoint. The Google library understands none of these.

So it falls back to instance metadata and either:

  • gets the node’s IAM role, the wrong identity, which may have more or less access than intended, or
  • times out, because IMDSv2 with a hop limit of 1 correctly blocks pods from the node role.
Two working paths from an EKS pod to Google Cloud — OIDC provider with a projected token, or AWS provider with a custom supplier — and the trap of falling back to the node's metadata role
On EKS, either let Google trust the cluster's OIDC issuer, or feed the AWS SDK's credentials to Google's library yourself. Never rely on the default fallback.

Every EKS cluster already has a public OIDC issuer. Google can trust it directly, and the pod authenticates with a Kubernetes service account token. No EKS changes and no IRSA or Pod Identity are needed for this part.

ISSUER=$(aws eks describe-cluster --name prod-eu-1 \
  --query "cluster.identity.oidc.issuer" --output text)

gcloud iam workload-identity-pools providers create-oidc eks-prod-eu-1 \
  --project=platform-identity \
  --location=global \
  --workload-identity-pool=aws-prod \
  --issuer-uri="$ISSUER" \
  --attribute-mapping="google.subject=assertion.sub,attribute.namespace=assertion['kubernetes.io']['namespace']" \
  --attribute-condition="assertion['kubernetes.io']['namespace'] in ['orders', 'analytics']"

gcloud iam workload-identity-pools create-cred-config \
  projects/123456789012/locations/global/workloadIdentityPools/aws-prod/providers/eks-prod-eu-1 \
  --credential-source-file=/var/run/service-account/token \
  --credential-source-type=text \
  --output-file=credential-configuration.json

kubectl create configmap gcp-wif-config -n orders --from-file=credential-configuration.json

Grant access to the Kubernetes service account as the principal:

gcloud storage buckets add-iam-policy-binding gs://analytics-prod-reports \
  --role=roles/storage.objectViewer \
  --member="principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/aws-prod/subject/system:serviceaccount:orders:orders-reporter"

Mount a projected token whose audience is the provider, together with the config:

spec:
  serviceAccountName: orders-reporter
  containers:
  - name: app
    image: …/orders-reporter:1.8.0
    env:
    - name: GOOGLE_APPLICATION_CREDENTIALS
      value: /etc/gcp/credential-configuration.json
    volumeMounts:
    - { name: gcp-token, mountPath: /var/run/service-account, readOnly: true }
    - { name: gcp-config, mountPath: /etc/gcp, readOnly: true }
  volumes:
  - name: gcp-token
    projected:
      sources:
      - serviceAccountToken:
          audience: https://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/aws-prod/providers/eks-prod-eu-1
          expirationSeconds: 3600
          path: token
  - name: gcp-config
    configMap:
      name: gcp-wif-config

The kubelet refreshes the token file before it expires, and the Google library re-reads it. Any standard Google client library works without code changes.

One trade-off: each cluster has its own issuer, so every cluster needs its own provider. Automate it with Terraform.

Path C: AWS provider + custom credential supplier

Sometimes you want Google to see the IAM role, the same identity the workload uses everywhere else in AWS, for example to keep one permission model across EC2, Lambda and EKS. Google’s client libraries let you plug in an AWS security credentials supplier. Back it with the AWS SDK, whose default chain understands Pod Identity, IRSA and ECS task roles:

import boto3
from google.auth import aws, exceptions
from google.cloud import storage

class Boto3CredentialsSupplier(aws.AwsSecurityCredentialsSupplier):
    """Hands the AWS SDK's credentials (Pod Identity, IRSA, ECS, env…) to google-auth."""

    def __init__(self):
        self._session = boto3.Session()

    def get_aws_security_credentials(self, context, request):
        try:
            c = self._session.get_credentials().get_frozen_credentials()  # refreshed by boto3
            return aws.AwsSecurityCredentials(c.access_key, c.secret_key, c.token)
        except Exception as e:
            raise exceptions.RefreshError(e, retryable=True)

    def get_aws_region(self, context, request):
        return self._session.region_name or "us-east-1"

credentials = aws.Credentials(
    audience="//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/aws-prod/providers/aws-prod",
    subject_token_type="urn:ietf:params:aws:token-type:aws4_request",
    aws_security_credentials_supplier=Boto3CredentialsSupplier(),
    scopes=["https://www.googleapis.com/auth/cloud-platform"],
)

client = storage.Client(project="analytics-prod", credentials=credentials)

The Go, Java and Node.js auth libraries offer the same supplier interface. Grants then use attribute.aws_role/<role-name>, exactly as in path A.

The 127-byte google.subject limit

google.subject can’t be longer than 127 bytes. Google STS checks it on every exchange, so a mapping that worked for months fails as soon as one workload brings a longer ARN:

"error": "invalid_request",
"error_description": "The size of mapped attribute google.subject exceeds the 127 bytes limit.
Either modify your attribute mapping or the incoming assertion to produce a mapped attribute
that is less than 127 bytes."

With google.subject=assertion.arn, the subject is the whole assumed-role ARN, session name included:

arn:aws:sts::444444444444:assumed-role/<role-name>/<session-name>
└───────────── 39 bytes ──────────────┘└─ ≤ 64 ──┘ └─── ≤ 64 ───┘

Role names and session names can each be 64 characters, so the ARN can reach 168 bytes. Once role plus session pass 87 characters, the exchange fails. You don’t choose the session name either: EC2 uses the instance ID (short, safe), but Lambda uses the function name, ECS uses the task ID, and tools that call AssumeRole pick their own. A real example that fails by a single byte:

Value Bytes
Role analytics-prod-reporting-exporter-eu-west-1 43
Session (Lambda function name) analytics-prod-reporting-exporter-nightly-job 45
assertion.arn arn:aws:sts::444444444444:assumed-role/analytics-prod-reporting-exporter-eu-west-1/analytics-prod-reporting-exporter-nightly-job 128 ✗
account:role 444444444444:analytics-prod-reporting-exporter-eu-west-1 56 ✓

The same role works from EC2 and breaks from Lambda, which makes it look like a Lambda bug. It isn’t.

The fix: map the subject to account:role. It drops the session and is bounded at 12 + 1 + 64 = 77 bytes, whatever runs behind the role:

gcloud iam workload-identity-pools providers update-aws aws-prod \
  --location=global --workload-identity-pool=aws-prod \
  --attribute-mapping="google.subject=assertion.account+':'+assertion.arn.extract('assumed-role/{role_name}/'),attribute.account=assertion.account,attribute.aws_role=assertion.arn.extract('assumed-role/{role_name}/')"

Things to know before you change it:

  • Bindings on attribute.aws_role keep working. They don’t use the subject, which is why this article grants access with principalSet://…/attribute.aws_role/<role>.
  • Bindings on principal://…/subject/<arn> break. They name the old subject. Rewrite them as principal://…/subject/444444444444:<role> (one per role, not per session) or move them to attribute.aws_role.
  • Audit logs lose the session name. Cloud Audit Logs show the subject, so you’ll see the role instead of the exact EC2 instance or Lambda function. Use CloudTrail on the AWS side, where the session is still recorded, when you need to go deeper.
  • The account prefix matters. A bare role name would be shorter, but two accounts in the same pool could then produce the same subject. account:role stays unique.
  • The attribute condition still protects you. It only lets assumed-role ARNs through, so extract('assumed-role/{role_name}/') always finds a role name.

EKS with the OIDC provider (path B) has the same limit: its subject is system:serviceaccount:<namespace>:<serviceaccount>, 22 bytes of prefix plus your names. Short names stay well inside it, but very long namespace and service account names can hit it too.

Hub and spoke for many accounts and projects

With a handful of AWS accounts, dozens of GCP projects and a few EKS clusters, keep federation centralised:

AWS accounts and EKS clusters trusted by providers in prod and non-prod pools inside one GCP identity project, which many GCP projects grant access to
Pools live in one identity project. Resource projects only add IAM bindings that point at a pool. Nothing changes on the AWS side when a new GCP project joins.
  • One identity project (platform-identity) holds all pools and providers, owned by the platform team.
  • One pool per trust boundary. aws-prod trusts only production accounts and clusters, aws-nonprod the rest. attribute.aws_role is just a role name, so if both environments shared a pool, orders-reporter in dev could match a prod binding. Separate pools rule that out.
  • One provider per AWS account (AWS type) and one per EKS cluster (OIDC type).
  • Resource projects only add bindings to principalSet://…/workloadIdentityPools/aws-prod/…. Onboarding project number 28 is a single Terraform change.
resource "google_iam_workload_identity_pool" "aws_prod" {
  project                   = "platform-identity"
  workload_identity_pool_id = "aws-prod"
}

resource "google_iam_workload_identity_pool_provider" "aws_prod" {
  project                            = "platform-identity"
  workload_identity_pool_id          = google_iam_workload_identity_pool.aws_prod.workload_identity_pool_id
  workload_identity_pool_provider_id = "aws-prod"
  attribute_mapping = {
    "google.subject"     = "assertion.account + ':' + assertion.arn.extract('assumed-role/{role_name}/')"
    "attribute.account"  = "assertion.account"
    "attribute.aws_role" = "assertion.arn.extract('assumed-role/{role_name}/')"
  }
  attribute_condition = "assertion.arn.startsWith('arn:aws:sts::444444444444:assumed-role/')"
  aws {
    account_id = "444444444444"
  }
}

resource "google_storage_bucket_iam_member" "reports_reader" {
  bucket = "analytics-prod-reports"
  role   = "roles/storage.objectViewer"
  member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.aws_prod.name}/attribute.aws_role/orders-reporter"
}

Security checklist

  • Always set an attribute condition. Restrict to assumed roles in specific accounts, or to specific namespaces for EKS. A provider without a condition trusts every identity in that account, including IAM users.
  • Ban keys once federation works. Enforce the iam.disableServiceAccountKeyCreation organisation policy so nobody falls back to key.json.
  • Restrict who can add providers. The iam.workloadIdentityPoolAwsAccounts and iam.workloadIdentityPoolProviders organisation policies limit which AWS accounts and issuers can be trusted at all.
  • Prefer direct resource access. Fewer service accounts, and audit logs show the real AWS principal instead of a shared service account.
  • Least privilege on both sides. The AWS role is now also a Google identity. Keep it dedicated to one workload, and don’t reuse broad roles such as a node role.
  • Block instance metadata for pods (IMDSv2, hop limit 1), so a misconfigured pod fails loudly instead of silently borrowing the node role.

Troubleshooting

Symptom Likely cause
The given credential is rejected by the attribute condition Condition doesn’t match. Check the account ID, or the namespace for EKS.
Permission 'iam.serviceAccounts.getAccessToken' denied Impersonation without roles/iam.workloadIdentityUser on the service account
403 from the API, token exchange worked No binding on the resource, or the product needs impersonation instead of direct access
Works on EC2, wrong identity or timeout on EKS / ECS The library fell back to the node’s instance metadata. Use path B or C.
Unable to determine the AWS region No metadata service. Set AWS_REGION.
The size of mapped attribute google.subject exceeds the 127 bytes limit google.subject maps the full ARN and role + session name are too long. Map account:role instead (see above).
429 Too Many Requests · The request was throttled due to rate limit: sts.googleapis.com/requests The project ran out of Security Token Service quota. Usually clients that don’t reuse tokens (see below).

429 Too Many Requests from Google STS

429 Too Many Requests
The request was throttled due to rate limit: sts.googleapis.com/requests. Please retry after a few seconds.

This means the project has used up its rate quota for the Security Token Service API. The default is 6,000 token exchanges per project per minute (regional endpoints have their own 6,000 per region). If you impersonate a service account, the second hop, generateAccessToken, counts against a separate quota of 60,000 requests per project per minute.

6,000 a minute is a lot for well-behaved clients, because a federated token lasts an hour. When you hit it, the cause is almost always a client that exchanges a new token for every request instead of reusing one:

  • A new credentials object per call. Google’s auth libraries cache the token inside the credentials object and refresh it shortly before it expires. Code that builds a new client, or calls google.auth.default(), for every request or every message throws that cache away. Create the client once per process and reuse it.
  • Short-lived processes at scale. Each Lambda cold start, CronJob pod or CLI invocation starts with an empty cache and does a full exchange. A few thousand per minute is enough to hit the limit. Keep clients in global scope so warm Lambda invocations reuse them, and batch work into fewer, longer-lived processes where you can.
  • Retry storms. A failing dependency plus aggressive retries turns one exchange into dozens. Retry 429s with exponential backoff and jitter, as the error message itself asks.
  • Hub-and-spoke growth. Every workload in every AWS account exchanges through the same identity project, so its quota is shared by all of them. That is fine, but it’s the number to watch as onboarding continues.

To fix it, first find which workload is generating the exchanges: the AWS principal that called STS is the google.subject in Cloud Audit Logs if you’ve enabled Data Access audit logs for the Security Token Service. Fix the caching there. If the traffic is legitimate, open IAM & Admin → Quotas & System Limits in the project, look up the sts.googleapis.com/requests metric shown in the error, confirm usage is at the limit, and request an increase.

Wrap-up

Workload Identity Federation turns “copy a JSON key to AWS” into configuration with nothing secret in it. On EC2 and Lambda, an AWS provider plus a generated config file is all you need. On EKS, trust the cluster’s OIDC issuer (path B) or feed the AWS SDK’s credentials through a custom supplier (path C), and never rely on the default fallback to instance metadata. At scale, centralise pools in one identity project, split them by trust boundary, and let every GCP project grant access with a single IAM binding.

Sources

Comments