Cross-Cloud OIDC Federation: Portable Workload Identity Across AWS, GCP, and Azure
Problem
Every major cloud has its own IAM model. AWS has roles, policies, and STS. GCP has service accounts, Workload Identity Federation, and the Security Token Service API. Azure has managed identities, workload identity federation, and Entra ID. None of these systems natively trust each other. A workload running on an EC2 instance can assume AWS IAM roles via the instance metadata service, but it cannot directly authenticate to GCP or Azure — it holds AWS credentials, which neither of those clouds know how to verify.
The traditional workarounds are all variations of the same mistake: static credentials. A service account key JSON file stored in AWS Secrets Manager. An Azure service principal secret injected as an environment variable. A GCP API key in a Kubernetes secret. These patterns create long-lived credentials that can be exfiltrated, rotated infrequently, and are hard to scope precisely to the workload that needs them.
The structural solution is OIDC federation. All three major clouds, plus Kubernetes, can act as OIDC token issuers. AWS STS, GCP Workload Identity Federation (WIF), and Azure Entra ID can all be configured to accept OIDC JWTs from external issuers and exchange them for short-lived cloud credentials. This makes it possible for a workload to hold one identity — issued by its own runtime environment — and exchange it for temporary credentials in any cloud that has been configured to trust that issuer.
Target systems: AWS IAM (STS AssumeRoleWithWebIdentity), GCP Workload Identity Federation (iam.googleapis.com), Azure Entra ID Workload Identity Federation, Kubernetes 1.21+ (ServiceAccount token volume projection), GitHub Actions OIDC, SPIFFE/SPIRE 1.9+.
Threat Model
- Adversary 1 — Token theft across cloud boundary: attacker captures a JWT issued by cloud A’s OIDC endpoint and replays it against cloud B’s token exchange endpoint, obtaining credentials in cloud B.
- Adversary 2 — Overly permissive attribute condition: a GCP WIF pool is configured to trust any JWT from a Kubernetes cluster’s OIDC issuer. An attacker with pod exec access creates a pod with a service account that maps to a privileged GCP role.
- Adversary 3 — Federation chain escalation: a compromised AWS IAM role can exchange its session token for a GCP service account token. Attacker uses a low-privilege AWS role breach to reach high-privilege GCP resources.
- Adversary 4 — OIDC issuer spoofing: attacker registers a malicious JWKS endpoint that mimics a trusted issuer’s key material after a key rotation failure.
- Access level: Adversaries 1 and 2 have network-level or pod-level access. Adversary 3 has valid but limited credentials in one cloud. Adversary 4 requires control of DNS or the WIF pool configuration.
- Blast radius: A misconfigured cross-cloud trust can grant an attacker simultaneous access to resources in multiple cloud accounts — a much larger blast radius than a single-cloud credential compromise.
The OIDC Exchange Mechanism
Every OIDC federation flow follows the same protocol regardless of which clouds are involved:
- The workload’s runtime issues a signed JWT. The token contains
iss(issuer URL),sub(subject — the workload’s identity),aud(audience — the token exchange endpoint), andiat/exptimestamps. - The workload presents this JWT to the target cloud’s token exchange endpoint.
- The target cloud fetches the issuer’s JWKS from
{iss}/.well-known/openid-configuration, validates the JWT signature, checks theaudclaim, evaluates attribute conditions, and issues short-lived cloud credentials. - The workload uses those short-lived credentials to make API calls. Credentials expire in 1–12 hours depending on configuration; the workload re-exchanges on expiry.
No static credentials are stored anywhere. The trust is established at configuration time by registering the issuer’s JWKS URL with the target cloud’s IAM system.
Pattern 1: Kubernetes OIDC as Universal Identity Anchor
A Kubernetes cluster with a stable OIDC issuer URL can be registered simultaneously with AWS IRSA and GCP WIF, allowing the same pod service account token to be exchanged for credentials in both clouds in the same workload.
See AWS IRSA Workload Identity and GCP Workload Identity Federation for the individual setup. The cross-cloud configuration adds a GCP WIF pool alongside the existing AWS trust.
GCP side — create the WIF pool for the Kubernetes cluster:
PROJECT_ID="my-project"
CLUSTER_OIDC_ISSUER="https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
gcloud iam workload-identity-pools create k8s-prod-pool \
--location=global \
--display-name="Kubernetes prod cluster"
gcloud iam workload-identity-pools providers create-oidc k8s-prod-provider \
--location=global \
--workload-identity-pool=k8s-prod-pool \
--issuer-uri="${CLUSTER_OIDC_ISSUER}" \
--attribute-mapping="google.subject=assertion.sub,attribute.namespace=assertion['kubernetes.io/serviceaccount/namespace'],attribute.serviceaccount=assertion['kubernetes.io/serviceaccount/service-account-name']" \
--attribute-condition="attribute.namespace=='payments' && attribute.serviceaccount=='billing-api'"
The attribute-condition is the critical security control. Without it, any pod in the cluster with any service account can exchange for this GCP service account’s permissions. Scope it to the exact namespace and service account name.
GCP side — bind the WIF identity to a service account:
GCP_SA="billing-api@${PROJECT_ID}.iam.gserviceaccount.com"
WIF_POOL="projects/${PROJECT_ID}/locations/global/workloadIdentityPools/k8s-prod-pool"
gcloud iam service-accounts add-iam-policy-binding "${GCP_SA}" \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://${WIF_POOL}/providers/k8s-prod-provider/attribute.serviceaccount/billing-api"
Kubernetes pod — project both tokens simultaneously:
apiVersion: v1
kind: ServiceAccount
metadata:
name: billing-api
namespace: payments
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/billing-api-role
---
apiVersion: v1
kind: Pod
spec:
serviceAccountName: billing-api
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken:
audience: sts.amazonaws.com
expirationSeconds: 3600
path: token
- name: gcp-token
projected:
sources:
- serviceAccountToken:
audience: https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/k8s-prod-pool/providers/k8s-prod-provider
expirationSeconds: 3600
path: token
containers:
- name: billing-api
volumeMounts:
- name: aws-token
mountPath: /var/run/secrets/aws
- name: gcp-token
mountPath: /var/run/secrets/gcp
The pod holds two projected tokens with different aud values. The AWS SDK picks up the token at /var/run/secrets/aws/token via AWS_WEB_IDENTITY_TOKEN_FILE. The GCP credential exchange uses the token at /var/run/secrets/gcp/token with the Workload Identity credential configuration file:
gcloud iam workload-identity-pools create-cred-config \
"projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/k8s-prod-pool/providers/k8s-prod-provider" \
--credential-source-file=/var/run/secrets/gcp/token \
--credential-source-type=text \
--output-file=/etc/gcp/credentials.json
export GOOGLE_APPLICATION_CREDENTIALS=/etc/gcp/credentials.json
The GCP client libraries read GOOGLE_APPLICATION_CREDENTIALS, parse the credential configuration file, and call the STS token exchange automatically. No code changes required.
Pattern 2: AWS → GCP Federation Chain
A workload running on an EC2 instance or ECS task holds an AWS IAM role identity. That identity — expressed as an AWS STS signed request — can be federated into GCP WIF. This avoids deploying any additional identity infrastructure; the EC2 instance metadata service is the identity anchor.
GCP WIF supports an aws provider type specifically for this:
gcloud iam workload-identity-pools providers create-aws ec2-prod-provider \
--location=global \
--workload-identity-pool=aws-to-gcp-pool \
--account-id="123456789012" \
--attribute-mapping="google.subject=assertion.arn,attribute.aws_role=assertion.arn.extract('assumed-role/{role}/')" \
--attribute-condition="attribute.aws_role=='ec2-data-pipeline'"
The account-id restricts which AWS account’s identities can federate in. The attribute-condition limits it further to the specific IAM role name. The extract() call parses the ARN format arn:aws:sts::123456789012:assumed-role/ec2-data-pipeline/i-0abc123 to isolate the role name.
From the EC2 instance, exchange the AWS identity for a GCP access token:
pip install google-auth google-auth-httplib2
python3 - <<'EOF'
import google.auth
import google.auth.transport.requests
from google.oauth2 import credentials
# google-auth handles AWS → GCP exchange automatically when
# GOOGLE_APPLICATION_CREDENTIALS points to an AWS credential config
import google.auth
creds, project = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
creds.refresh(google.auth.transport.requests.Request())
print(creds.token)
EOF
The credential configuration file for the AWS case specifies credential_source.environment_id: aws1 and the regional STS endpoint; the library fetches the signed GetCallerIdentity request from the EC2 metadata service and presents it to GCP STS.
Pattern 3: GitHub Actions as Universal OIDC Anchor
GitHub Actions issues OIDC tokens for every workflow run. The token’s sub claim encodes the repository, branch, and environment: repo:org/repo:environment:production. Both AWS and GCP can be configured to trust the GitHub OIDC issuer (https://token.actions.githubusercontent.com), allowing a single workflow to access both clouds without any stored secrets.
AWS trust policy for GitHub Actions:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
}
}
}]
}
GCP WIF pool for GitHub Actions:
gcloud iam workload-identity-pools providers create-oidc github-actions-provider \
--location=global \
--workload-identity-pool=github-pool \
--issuer-uri="https://token.actions.githubusercontent.com" \
--allowed-audiences="https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-actions-provider" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.environment=assertion.environment" \
--attribute-condition="attribute.repository=='myorg/myrepo' && attribute.environment=='production'"
Workflow using both clouds in sequence:
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy-role
aws-region: us-east-1
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-actions-provider
service_account: deployer@my-project.iam.gserviceaccount.com
- run: aws s3 sync dist/ s3://my-bucket/
- run: gcloud run deploy my-service --image gcr.io/my-project/my-service:${{ github.sha }}
The id-token: write permission enables the OIDC token request. Each cloud action exchanges the same underlying GitHub JWT (with different aud values) for its own short-lived credentials. The two credential sets are independent; the AWS credentials cannot be used to obtain GCP credentials or vice versa.
Scoping Cross-Cloud Trust with Attribute Conditions
Attribute conditions are the primary mechanism for preventing federation sprawl. Every WIF provider and every AWS STS trust policy should express the minimum viable condition that identifies the specific workload.
Kubernetes conditions — minimum viable set:
| Attribute | Claim | Example value |
|---|---|---|
| Namespace | kubernetes.io/serviceaccount/namespace |
payments |
| Service account name | kubernetes.io/serviceaccount/service-account-name |
billing-api |
| Cluster UID | kubernetes.io/serviceaccount/controller-uid |
a1b2c3d4-... |
Do not use only sub (system:serviceaccount:payments:billing-api) without also binding to a specific cluster’s issuer URL. If you run multiple clusters and all have the same service account names, a compromise in one cluster can federate into the other cluster’s cloud roles.
GitHub conditions — pin to environment, not just repository:
attribute.repository == 'myorg/myrepo'
&& attribute.environment == 'production'
&& attribute.ref == 'refs/heads/main'
Omitting environment allows any branch build to exchange for production credentials. Omitting ref allows pull request workflows from forks to access production resources if the repo has open PRs from external contributors.
AWS → GCP conditions — pin to role session name pattern:
assertion.arn.startsWith('arn:aws:sts::123456789012:assumed-role/data-pipeline/')
The session name (the part after the role name in an assumed-role ARN) is set by the caller. For EC2 instances the session name is the instance ID. For ECS tasks it’s the task ID. Including it in the condition bounds the grant to specific running instances rather than anyone who can assume the role.
SPIFFE as a Cross-Cloud Identity Layer
SPIFFE/SPIRE provides a platform-agnostic identity layer that can federate into cloud IAM on one side and issue mTLS certificates for service-to-service communication on the other. A SPIRE deployment spanning Kubernetes, EC2, and on-premises VMs issues JWT-SVIDs with iss set to the SPIRE server’s trust domain URL. Those JWT-SVIDs can be registered as a WIF identity provider in GCP or as an OIDC provider in AWS IAM.
Register a SPIRE trust domain as a GCP WIF provider:
SPIRE_BUNDLE_ENDPOINT="https://spire.internal.example.com/bundle.json"
gcloud iam workload-identity-pools providers create-oidc spire-prod-provider \
--location=global \
--workload-identity-pool=spire-pool \
--issuer-uri="https://spire.internal.example.com" \
--jwks-uri="${SPIRE_BUNDLE_ENDPOINT}" \
--attribute-mapping="google.subject=assertion.sub,attribute.spiffe_id=assertion.sub" \
--attribute-condition="attribute.spiffe_id.startsWith('spiffe://prod.example.com/ns/payments/')"
The SPIRE agent on each workload delivers JWT-SVIDs via the Workload API. The workload reads its SVID, presents it to GCP STS, and receives a GCP access token scoped to its service account. The chain from workload attestation (Kubernetes pod metadata, AWS instance identity document, TPM-based node attestation) to GCP credentials is entirely automatic and requires no secrets at any point.
This pattern is particularly useful when workloads span multiple clouds and need a common identity substrate. The SPIFFE trust domain acts as the source of truth; each cloud’s WIF pool consumes it. Adding a new cloud requires only registering the SPIRE JWKS endpoint with that cloud’s federation mechanism — no changes to the workloads themselves.
Token Chaining Attacks and Prevention
A federation chain introduces a new attack vector: a compromised identity in cloud A can be used to obtain credentials in cloud B if the chain allows it. The risk compounds across hops.
The attack path:
- Attacker compromises a low-privilege AWS IAM role (e.g., through SSRF to the EC2 metadata service or credential leakage in CI logs).
- That role is configured as the trust principal for a GCP WIF pool.
- Attacker calls GCP STS with the AWS identity and receives a GCP service account token.
- The GCP service account has Storage Admin on a bucket containing sensitive data — a higher privilege than the original AWS role.
Prevention controls:
1. Never grant higher privilege at the federation destination than at the source. A low-privilege AWS role should map to a low-privilege GCP service account. Audit every workloadIdentityUser binding and compare the permissions of the GCP service account to the permissions of the AWS role that can federate into it.
2. Use attribute conditions to limit federation to specific roles, not entire accounts. An account-level trust condition (account-id only, no attribute-condition) means any role in that account can federate in. An attacker who compromises any role in the account gains access.
3. Apply GCP service account constraints. GCP allows setting iam.disableCrossProjectServiceAccountUsage at the organization policy level. Apply this to prevent service accounts in project A from being used by resources in project B via WIF.
4. Rotate OIDC issuer keys on schedule. If an issuer’s signing key is compromised, tokens signed with that key can be forged. AWS automatically rotates the OIDC provider’s keys. For self-managed SPIRE, configure key rotation in the server configuration:
# spire-server.conf
ca_key_type = "ec-p384"
ca_ttl = "168h"
jwt_key_type = "ec-p256"
5. Restrict token exchange with organization policies (GCP):
gcloud org-policies set-policy - <<'EOF'
name: organizations/ORG_ID/policies/iam.workloadIdentityPoolProviders
spec:
rules:
- values:
allowedValues:
- "https://token.actions.githubusercontent.com"
- "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
- "https://spire.internal.example.com"
EOF
This prevents arbitrary OIDC issuers from being registered as WIF providers within the organization — closing the path where an attacker with project-level IAM write access registers a malicious issuer.
Audit Correlation Across Cloud Boundaries
A single federated request leaves audit trail entries in multiple systems. Correlating them requires understanding which field in each log carries the external token identity.
AWS CloudTrail — AssumeRoleWithWebIdentity event:
{
"eventName": "AssumeRoleWithWebIdentity",
"requestParameters": {
"roleArn": "arn:aws:iam::123456789012:role/billing-api-role",
"webIdentityToken": "REDACTED"
},
"responseElements": {
"assumedRoleUser": {
"assumedRoleId": "AROA...:billing-api-pod",
"arn": "arn:aws:sts::123456789012:assumed-role/billing-api-role/billing-api-pod"
}
},
"additionalEventData": {
"identityProviderArn": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE",
"federatedUserId": "system:serviceaccount:payments:billing-api"
}
}
The federatedUserId field contains the sub claim from the Kubernetes JWT. This is the correlation key.
GCP Cloud Audit Logs — GenerateAccessToken event:
{
"protoPayload": {
"methodName": "iam.googleapis.com/GenerateAccessToken",
"authenticationInfo": {
"principalSubject": "principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/k8s-prod-pool/providers/k8s-prod-provider/attribute.serviceaccount/billing-api"
},
"requestMetadata": {
"callerIp": "10.0.1.45"
}
}
}
The principalSubject encodes the WIF pool, provider, and matched attribute. The mapped service account name (billing-api) matches the federatedUserId in the CloudTrail entry.
Correlation query (Cloud Logging):
resource.type="service_account"
protoPayload.methodName="iam.googleapis.com/GenerateAccessToken"
protoPayload.authenticationInfo.principalSubject=~"attribute.serviceaccount/billing-api"
timestamp >= "2026-05-09T00:00:00Z"
Match the timestamp range and caller IP against the CloudTrail AssumeRoleWithWebIdentity event. For GitHub Actions workflows, the GitHub JWT contains a run_id claim that appears in both the AWS and GCP token exchange logs — use it as the single correlation key across both audit trails.
Structured log enrichment: configure workloads to emit a trace ID at startup that is included in all outbound API calls. Both cloud SDKs support request metadata; include the trace ID in the x-goog-request-reason header for GCP calls and as a RequestPayer or custom tag for AWS calls. This creates a workload-level correlation that survives the per-cloud audit log boundary.
Configuration Checklist
- Every WIF provider has an
attribute-conditionthat scopes trust to specific workloads, not entire clusters or AWS accounts. - AWS STS trust policies use
StringEqualsconditions onsubandaud, notStringLikewith wildcards. - GCP service accounts used in cross-cloud federation have the minimum IAM role bindings needed; no Owner, Editor, or Project-level primitives.
- OIDC issuers are registered at the organization policy level; ad hoc project-level registrations are blocked.
- Token expiry is set to 1 hour maximum for cross-cloud exchanges; review any configuration using the 12-hour maximum.
- CloudTrail and Cloud Audit Logs are shipped to a centralized SIEM; alerts exist for
AssumeRoleWithWebIdentitycalls from unexpected source IPs or at unexpected hours. - SPIRE key rotation is automated; bundle endpoint TLS certificate is monitored for expiry.
- Cross-cloud privilege comparison is part of quarterly IAM review: for every WIF pool provider binding, the permissions at the destination do not exceed the permissions at the source.
Related Articles
- AWS IRSA Workload Identity — single-cloud OIDC setup on EKS
- GCP Workload Identity Federation — WIF pool and provider configuration
- Azure Workload Identity for AKS — Azure Entra ID federation patterns
- SPIFFE and SPIRE Workload Identity — cross-cluster identity substrate