Workload Identity Federation from Google Cloud to AWS — no access keys

Let GCE, Cloud Run, Cloud Functions and GKE workloads call AWS with temporary credentials: Google ID tokens and AssumeRoleWithWebIdentity, the trust-policy mistake that opens your role to the whole internet, the GKE OIDC path, and Terraform for both sides.

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

A service on Google Cloud needs to drop files into S3, read an SQS queue or fetch a secret from AWS Secrets Manager. The classic answer is an IAM user with an access key, stored in Secret Manager and injected as AWS_ACCESS_KEY_ID. That key never expires, is valid from anywhere, and becomes a breach the day it leaks.

AWS doesn’t need any of that. Every GCP workload already has an identity, its service account, and Google will sign an OIDC ID token for it on request. AWS STS accepts that token in AssumeRoleWithWebIdentity and returns temporary credentials for an IAM role. No access key exists anywhere.

This is the reverse direction of AWS → Google Cloud. See also AWS → Azure, Workload Identity on GKE and Workload Identity on AKS.

How it works

The GCP workload gets an ID token for its service account from the metadata server, calls AWS STS AssumeRoleWithWebIdentity, STS verifies Google's signature and the role trust policy, and returns temporary credentials
The token proves which Google service account is calling. The role's trust policy decides whether that service account may become the role.
  1. The workload asks the GCP metadata server for an ID token with an audience you agree on, such as sts.amazonaws.com. Google signs a JWT containing iss=https://accounts.google.com, sub and azp (both the service account’s numeric unique ID) and aud (your audience).
  2. The workload calls sts:AssumeRoleWithWebIdentity with that token. The call isn’t signed with AWS credentials, because the token is the credential.
  3. AWS STS verifies Google’s signature and evaluates the role’s trust policy. Google is a built-in identity provider in AWS, so you don’t create an IAM OIDC provider for it.
  4. STS returns temporary credentials, valid for one hour by default.
  5. The workload calls S3, SQS and the rest like any other AWS client.

The mistake that opens your role to the whole internet

accounts.google.com is one issuer shared by every Google identity on the planet. Anyone with a free GCP project can create a service account and ask for an ID token with aud=sts.amazonaws.com. AWS doesn’t force you to check anything else: Google isn’t on the list of shared providers where IAM requires specific claims in the trust policy.

A trust policy that only checks the audience accepts tokens from any Google service account; pinning the subject to your service account's unique ID accepts only yours
Checking only the audience lets every Google service account in the world assume your role. Always pin the subject to your service account's unique ID.

The condition keys also don’t map the way you’d guess. Service account ID tokens set the azp claim, and when azp is present:

AWS condition key Matches token claim For a service account ID token
accounts.google.com:sub sub the SA’s unique ID (e.g. 107517467455664443765)
accounts.google.com:aud azp (if set), else aud the SA’s unique ID again
accounts.google.com:oaud aud the audience you requested (sts.amazonaws.com)
accounts.google.com:email email the SA email: don’t use it as the primary check

So a correct trust policy pins all three:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "accounts.google.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "accounts.google.com:sub":  "107517467455664443765",
        "accounts.google.com:aud":  "107517467455664443765",
        "accounts.google.com:oaud": "sts.amazonaws.com"
      }
    }
  }]
}

Use the unique ID, not the email. If someone deletes orders-reporter@shop-prod.iam.gserviceaccount.com and recreates it, the email comes back but the unique ID doesn’t. Your role stays bound to the original identity.

Two ways to get the token

Path A uses a Google service account ID token from the metadata server; path B uses a GKE Kubernetes ServiceAccount token verified through an IAM OIDC provider for the cluster issuer
Path A works wherever a Google service account is attached. Path B is GKE-only, but needs no IAM service account and no extra process.
Path A: Google SA ID token Path B: GKE ServiceAccount token
Runs on GCE, Cloud Run, Cloud Functions, GKE with a linked SA GKE pods
Identity AWS sees Google service account (unique ID) system:serviceaccount:<ns>:<name>
AWS setup role only (Google is built in) IAM OIDC provider per cluster + role
Getting the token metadata server, through a sidecar or a few lines of code kubelet projects it, and the AWS SDK reads it natively

Path A: a Google service account ID token

1. GCP: a dedicated service account

gcloud iam service-accounts create orders-reporter --project=shop-prod

gcloud iam service-accounts describe \
  orders-reporter@shop-prod.iam.gserviceaccount.com \
  --format='value(uniqueId)'
# 107517467455664443765

# attach it to the workload, e.g. Cloud Run
gcloud run deploy orders-reporter --project=shop-prod --region=europe-west1 \
  --image=europe-docker.pkg.dev/shop-prod/apps/orders-reporter:1.8.0 \
  --service-account=orders-reporter@shop-prod.iam.gserviceaccount.com

2. AWS: the role

aws iam create-role --role-name gcp-orders-reporter \
  --assume-role-policy-document file://trust-gcp-orders-reporter.json \
  --max-session-duration 3600

aws iam put-role-policy --role-name gcp-orders-reporter --policy-name s3-write \
  --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":"arn:aws:s3:::orders-exports/*"}]}'

trust-gcp-orders-reporter.json is the policy from the previous section.

3a. The workload, with no code: a token file

Every AWS SDK already knows AssumeRoleWithWebIdentity. Point it at a role and a token file, and it handles the exchange and refresh itself:

AWS_ROLE_ARN=arn:aws:iam::444444444444:role/gcp-orders-reporter
AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/aws/token
AWS_ROLE_SESSION_NAME=orders-reporter
AWS_REGION=eu-west-1

A small sidecar keeps the file fresh. Google ID tokens last at most an hour, so refresh every 30 minutes. On Cloud Run, use a sidecar container with a shared in-memory volume. On GCE, use a systemd timer.

while true; do
  curl -sf -H "Metadata-Flavor: Google" \
    "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=sts.amazonaws.com&format=full" \
    -o /var/run/aws/token.tmp \
  && mv /var/run/aws/token.tmp /var/run/aws/token
  sleep 1800
done

3b. The workload, in code (Python)

Without a sidecar, fetch the token in-process and let botocore refresh the credentials before they expire:

import boto3
import botocore.session
from botocore.credentials import RefreshableCredentials
from google.auth.transport.requests import Request
from google.oauth2 import id_token

ROLE_ARN = "arn:aws:iam::444444444444:role/gcp-orders-reporter"
sts = boto3.client("sts", region_name="eu-west-1")   # AssumeRoleWithWebIdentity is unsigned

def refresh():
    token = id_token.fetch_id_token(Request(), "sts.amazonaws.com")  # from the metadata server
    c = sts.assume_role_with_web_identity(
        RoleArn=ROLE_ARN, RoleSessionName="orders-reporter", WebIdentityToken=token,
    )["Credentials"]
    return {
        "access_key": c["AccessKeyId"],
        "secret_key": c["SecretAccessKey"],
        "token": c["SessionToken"],
        "expiry_time": c["Expiration"].isoformat(),
    }

core = botocore.session.get_session()
core._credentials = RefreshableCredentials.create_from_metadata(
    metadata=refresh(), refresh_using=refresh, method="gcp-web-identity",
)
s3 = boto3.Session(botocore_session=core, region_name="eu-west-1").client("s3")
s3.put_object(Bucket="orders-exports", Key="daily.csv", Body=b"…")

Test from a VM

TOKEN=$(curl -s -H "Metadata-Flavor: Google" \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=sts.amazonaws.com")

aws sts assume-role-with-web-identity \
  --role-arn arn:aws:iam::444444444444:role/gcp-orders-reporter \
  --role-session-name test --web-identity-token "$TOKEN" \
  --query 'AssumedRoleUser.Arn'
# "arn:aws:sts::444444444444:assumed-role/gcp-orders-reporter/test"

Path B: GKE pods with the cluster’s OIDC issuer

GKE clusters expose an OIDC issuer for their Kubernetes service account tokens. AWS can trust it like any other OIDC provider. This is the same mechanism as IRSA, just with GKE as the issuer. You don’t need an IAM service account on the Google side, and the AWS SDK reads the token file natively.

ISSUER=$(kubectl get --raw /.well-known/openid-configuration | jq -r .issuer)
# https://container.googleapis.com/v1/projects/shop-prod/locations/europe-west1/clusters/prod-eu-1
curl -s "$ISSUER/.well-known/openid-configuration" | jq .jwks_uri   # must be reachable by AWS

aws iam create-open-id-connect-provider \
  --url "$ISSUER" \
  --client-id-list sts.amazonaws.com
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::444444444444:oidc-provider/container.googleapis.com/v1/projects/shop-prod/locations/europe-west1/clusters/prod-eu-1"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "container.googleapis.com/v1/projects/shop-prod/locations/europe-west1/clusters/prod-eu-1:aud": "sts.amazonaws.com",
        "container.googleapis.com/v1/projects/shop-prod/locations/europe-west1/clusters/prod-eu-1:sub": "system:serviceaccount:orders:orders-reporter"
      }
    }
  }]
}
spec:
  serviceAccountName: orders-reporter
  containers:
  - name: app
    image: europe-docker.pkg.dev/shop-prod/apps/orders-reporter:1.8.0
    env:
    - { name: AWS_ROLE_ARN, value: arn:aws:iam::444444444444:role/gke-orders-reporter }
    - { name: AWS_WEB_IDENTITY_TOKEN_FILE, value: /var/run/secrets/aws/token }
    - { name: AWS_REGION, value: eu-west-1 }
    volumeMounts:
    - { name: aws-token, mountPath: /var/run/secrets/aws, readOnly: true }
  volumes:
  - name: aws-token
    projected:
      sources:
      - serviceAccountToken:
          audience: sts.amazonaws.com
          expirationSeconds: 3600
          path: token

The trade-off: one IAM OIDC provider per cluster, and an AWS account has a quota of OIDC providers (100 by default). Automate them, and delete providers when clusters go away.

Terraform: both sides in one plan

resource "google_service_account" "orders_reporter" {
  project    = "shop-prod"
  account_id = "orders-reporter"
}

data "aws_iam_policy_document" "trust_gcp" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = ["accounts.google.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "accounts.google.com:sub"
      values   = [google_service_account.orders_reporter.unique_id]
    }
    condition {
      test     = "StringEquals"
      variable = "accounts.google.com:aud"
      values   = [google_service_account.orders_reporter.unique_id]
    }
    condition {
      test     = "StringEquals"
      variable = "accounts.google.com:oaud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "gcp_orders_reporter" {
  name                 = "gcp-orders-reporter"
  assume_role_policy   = data.aws_iam_policy_document.trust_gcp.json
  max_session_duration = 3600
}

The unique ID flows straight from the Google resource into the AWS trust policy, so nobody copies a 21-digit number by hand.

Security checklist

  • Pin sub (and aud) to the unique ID and oaud to your audience. A policy without them trusts all of Google.
  • Control who can mint tokens on the GCP side. Anyone who can impersonate the service account can get an ID token for it, and therefore into AWS. Audit roles/iam.serviceAccountTokenCreator, roles/iam.serviceAccountOpenIdTokenCreator and iam.serviceAccounts.actAs (who can deploy workloads as that SA).
  • One service account per workload, one role per service account. Shared identities blur both audit trails.
  • Least privilege on the role and a short MaxSessionDuration.
  • Meaningful session names. CloudTrail records AssumeRoleWithWebIdentity with the Google identity, and the session name makes later API calls readable.
  • No access keys at all. Once federation works, delete the old IAM user and prevent new ones with an SCP.

Troubleshooting

Error Likely cause
AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity Trust conditions don’t match. Most often the audience was put in :aud instead of :oaud, or the email was used instead of the unique ID.
InvalidIdentityToken: Incorrect token audience Path B: the token’s audience isn’t in the OIDC provider’s client-id list
InvalidIdentityToken: Couldn't retrieve verification key Path B: wrong issuer URL, or AWS can’t reach the cluster’s discovery/JWKS endpoints
ExpiredTokenException The token file isn’t being refreshed. Check the sidecar or timer.
Works on a VM, not on GKE The pod has no linked IAM service account. Use path B or link one.

Wrap-up

Federating from Google Cloud to AWS is mostly an IAM trust policy: Google is a built-in provider, so a role that trusts accounts.google.com plus a few lines to fetch an ID token is all you need. Get one detail right: pin the service account’s unique ID in sub and aud, and the audience in oaud, because that issuer is shared by everyone. On GKE, trusting the cluster’s own OIDC issuer gives you an IRSA-style, zero-code setup. Combined with the AWS → Google Cloud guide, AWS and Google Cloud can now talk in both directions without a single long-lived key.

Sources

Comments