Workload Identity Federation from AWS to Azure — no client secrets

Let EC2, Lambda, ECS and EKS workloads call Azure with short-lived tokens: AWS IAM outbound identity federation, Entra federated credentials on managed identities, the EKS shortcut, and a hub-and-spoke design.

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

A service on AWS needs to read an Azure Storage account or a Key Vault secret. The usual shortcut is an Entra app registration with a client secret, stored in AWS Secrets Manager and injected as AZURE_CLIENT_SECRET. That secret expires on a schedule nobody remembers, gets copied into CI, and is valid from anywhere on the internet.

Workload identity federation replaces the secret with a trust relationship. The AWS workload presents a signed token that proves its AWS identity, and Microsoft Entra ID exchanges it for a short-lived Azure access token. No secret exists, so none can leak.

This is the Azure counterpart of Workload Identity Federation from AWS to Google Cloud. The two clouds work quite differently, and that difference shapes everything below.

Why Azure is different from GCP

Google has a native AWS provider: it accepts a signed GetCallerIdentity request and asks AWS who the caller is. Entra ID has no such thing. A federated identity credential only accepts an OIDC token (JWT) from an issuer that publishes a discovery document and signing keys. So the question becomes: where does an AWS workload get a JWT?

  • EKS pods have always had one: the cluster’s OIDC issuer signs Kubernetes service account tokens.
  • Everything else (EC2, Lambda, ECS, Glue) used to need workarounds, such as Cognito developer identities or a home-grown token broker. Since AWS launched IAM outbound identity federation, any IAM principal can call sts:GetWebIdentityToken and receive a JWT signed by AWS, with an issuer URL unique to your account.

How the exchange works

The AWS workload gets a JWT from AWS STS, sends it to Microsoft Entra ID as a client assertion, Entra verifies it with the AWS issuer keys and the federated credential, and returns an Azure access token
Entra never talks to AWS STS. It only checks the JWT's signature against the issuer's public keys and compares iss, sub and aud with a federated credential.
  1. The workload calls sts:GetWebIdentityToken with audience api://AzureADTokenExchange. AWS STS returns a JWT whose sub is the IAM role ARN (arn:aws:iam::444444444444:role/orders-reporter) and whose iss is your account’s issuer (https://<id>.tokens.sts.global.api.aws).
  2. It sends the JWT to Entra’s token endpoint as a client assertion (grant_type=client_credentials, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer).
  3. Entra fetches the issuer’s /.well-known/openid-configuration and jwks.json and verifies the signature.
  4. Entra looks for a federated identity credential (FIC) on the target managed identity whose issuer, subject and audience match the token exactly.
  5. Entra returns an Azure access token for that managed identity.
  6. The workload calls Azure. What it may do is decided by normal Azure RBAC role assignments.

Two ways to get the token

Path A uses AWS IAM outbound identity federation from any AWS compute with the role ARN as subject; path B uses the EKS OIDC issuer with the Kubernetes service account as subject
Both paths end in the same place: a JWT whose issuer, subject and audience match a federated credential.
Path A: IAM outbound federation Path B: EKS OIDC issuer
Works on EC2, Lambda, ECS, Glue, EKS, anything with an IAM role EKS pods only
Identity Azure sees IAM role ARN system:serviceaccount:<ns>:<name>
AWS setup Enable outbound federation once per account Nothing (the issuer already exists)
App change A token callback, or a small sidecar None (Azure SDK reads a token file)
Extra claims org_id, principal tags, Lambda/EC2 ARN namespace, service account, pod
FIC granularity one per account + role one per cluster + service account

Path A: IAM outbound identity federation

1. AWS: enable outbound federation and allow the role

aws iam enable-outbound-web-identity-federation
# returns the account-specific issuer, e.g. https://abc123-….tokens.sts.global.api.aws
aws iam get-outbound-web-identity-federation-info   # read it again later

Allow the workload’s role to request tokens only for Azure, only RS256, and only short-lived ones:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "sts:GetWebIdentityToken",
    "Resource": "*",
    "Condition": {
      "ForAllValues:StringEquals": { "sts:IdentityTokenAudience": "api://AzureADTokenExchange" },
      "StringEquals": { "sts:SigningAlgorithm": "RS256" },
      "NumericLessThanEquals": { "sts:DurationSeconds": 3600 }
    }
  }]
}

Use RS256. AWS also offers ES384, but Entra documents RS256 as the only tested algorithm for workload identity federation.

2. Azure: a managed identity with a federated credential

Use a user-assigned managed identity. It’s an ordinary Azure resource, manageable with Terraform and RBAC, and needs no app registration.

az identity create -g rg-identity -n id-orders-reporter

az identity federated-credential create \
  --name aws-prod-orders-reporter \
  --identity-name id-orders-reporter \
  --resource-group rg-identity \
  --issuer "https://abc123-def456-ghi789-jkl012.tokens.sts.global.api.aws" \
  --subject "arn:aws:iam::444444444444:role/orders-reporter" \
  --audiences "api://AzureADTokenExchange"

PRINCIPAL_ID=$(az identity show -g rg-identity -n id-orders-reporter --query principalId -o tsv)
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Reader" \
  --scope "/subscriptions/<sub-id>/resourceGroups/rg-analytics/providers/Microsoft.Storage/storageAccounts/analyticsprod"

The subject is the role ARN, not a session ARN. It’s stable across sessions, EC2 instances and Lambda invocations, which is exactly what Entra’s exact-match rule needs.

3. The application: a token callback

The Azure Identity library’s ClientAssertionCredential accepts a function that returns a fresh JWT. Back it with the AWS SDK, which already knows how to get role credentials on EC2, Lambda, ECS and EKS (Pod Identity or IRSA):

import boto3
from azure.identity import ClientAssertionCredential
from azure.storage.blob import BlobServiceClient

# GetWebIdentityToken is not available on the global STS endpoint: use a region
sts = boto3.client("sts", region_name="eu-west-1")

def aws_jwt() -> str:
    resp = sts.get_web_identity_token(
        Audience=["api://AzureADTokenExchange"],
        SigningAlgorithm="RS256",
        DurationSeconds=300,
    )
    return resp["WebIdentityToken"]

credential = ClientAssertionCredential(
    tenant_id="<entra-tenant-id>",
    client_id="<client-id of id-orders-reporter>",
    func=aws_jwt,  # called whenever Azure needs a new access token
)

blobs = BlobServiceClient("https://analyticsprod.blob.core.windows.net", credential=credential)

The callback runs only when the Azure SDK needs a new access token, so a 5-minute JWT lifetime is plenty.

No-code alternative: a token file

If you can’t touch the code, write the JWT to a file and let the standard WorkloadIdentityCredential, which is part of DefaultAzureCredential, read it. This needs a sidecar container on ECS or EKS, or a systemd timer on EC2:

# refresh loop (sidecar): token valid 1h, refreshed every 30 min
while true; do
  aws sts get-web-identity-token --region eu-west-1 \
    --audience api://AzureADTokenExchange --signing-algorithm RS256 --duration-seconds 3600 \
    --query WebIdentityToken --output text > /var/run/azure/token.tmp \
  && mv /var/run/azure/token.tmp /var/run/azure/token
  sleep 1800
done
# app container environment
AZURE_TENANT_ID=<entra-tenant-id>
AZURE_CLIENT_ID=<client-id of id-orders-reporter>
AZURE_FEDERATED_TOKEN_FILE=/var/run/azure/token

Test it from a shell

JWT=$(aws sts get-web-identity-token --region eu-west-1 \
  --audience api://AzureADTokenExchange --signing-algorithm RS256 \
  --query WebIdentityToken --output text)

curl -s "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
  -d client_id=<client-id> \
  -d scope=https://storage.azure.com/.default \
  -d grant_type=client_credentials \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  -d client_assertion="$JWT" | jq -r '.access_token | length'

A positive number means Azure accepted your AWS identity.

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

For EKS you don’t need outbound federation at all. Trust the cluster’s issuer and give the pod a service account token whose audience is Azure’s:

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

az identity federated-credential create \
  --name eks-prod-eu-1-orders-reporter \
  --identity-name id-orders-reporter \
  --resource-group rg-identity \
  --issuer "$ISSUER" \
  --subject "system:serviceaccount:orders:orders-reporter" \
  --audiences "api://AzureADTokenExchange"
spec:
  serviceAccountName: orders-reporter
  containers:
  - name: app
    image: …/orders-reporter:1.8.0
    env:
    - { name: AZURE_TENANT_ID, value: "<entra-tenant-id>" }
    - { name: AZURE_CLIENT_ID, value: "<client-id of id-orders-reporter>" }
    - { name: AZURE_FEDERATED_TOKEN_FILE, value: /var/run/secrets/azure/tokens/azure-identity-token }
    volumeMounts:
    - { name: azure-token, mountPath: /var/run/secrets/azure/tokens, readOnly: true }
  volumes:
  - name: azure-token
    projected:
      sources:
      - serviceAccountToken:
          audience: api://AzureADTokenExchange
          expirationSeconds: 3600
          path: azure-identity-token

Any Azure SDK using DefaultAzureCredential picks this up with no code changes. If you’d rather not write the volume by hand, the open-source Azure Workload Identity mutating webhook also runs on non-AKS clusters and injects the same env vars and token.

The trade-off: every cluster has its own issuer, so you need one FIC per cluster and service account, and an identity holds at most 20 FICs.

Hub and spoke: one issuer for the whole organisation

With Path A, every AWS account has its own issuer. With twenty accounts and a few roles each, you’d be managing dozens of FICs and enabling outbound federation everywhere. A cleaner pattern is a central AWS identity account:

Workload accounts assume a role in a central AWS identity account, the only account with outbound federation enabled; Azure managed identities trust that one issuer
Only the identity account issues tokens for Azure. Workload accounts reach it through ordinary role chaining, and Azure trusts a single issuer.
  • Enable outbound federation only in the identity account (999999999999). It becomes the single issuer Azure trusts.
  • For each workload, create a role there, such as azure-orders-reporter, whose trust policy allows only the matching workload role in the prod account. Only that role may call GetWebIdentityToken, with the Azure audience.
  • Workloads AssumeRole into it, request the JWT, and exchange it at Entra. Each Azure managed identity has one FIC: the identity account’s issuer plus the hub role’s ARN.
  • Use an SCP to deny sts:GetWebIdentityToken everywhere else, so no other account can quietly become a trusted issuer.

You get one issuer to trust, a central CloudTrail record of every token handed to Azure, and an AWS-side kill switch: delete the hub role and the Azure access is gone.

The cost is one extra AssumeRole hop. Role chaining caps the hub session at one hour, which is fine because the SDK refreshes it.

Terraform (Azure side)

resource "azurerm_user_assigned_identity" "orders_reporter" {
  name                = "id-orders-reporter"
  resource_group_name = "rg-identity"
  location            = "westeurope"
}

resource "azurerm_federated_identity_credential" "aws_hub" {
  name                = "aws-hub-orders-reporter"
  resource_group_name = "rg-identity"
  parent_id           = azurerm_user_assigned_identity.orders_reporter.id
  issuer              = "https://abc123-def456-ghi789-jkl012.tokens.sts.global.api.aws"
  subject             = "arn:aws:iam::999999999999:role/azure-orders-reporter"
  audience            = ["api://AzureADTokenExchange"]
}

resource "azurerm_role_assignment" "reports_reader" {
  scope                = azurerm_storage_account.analytics.id
  role_definition_name = "Storage Blob Data Reader"
  principal_id         = azurerm_user_assigned_identity.orders_reporter.principal_id
}

Gotchas

  • Exact match, no wildcards. issuer, subject and audience must match the token character for character. A wrong subject creates the FIC happily and fails only at token exchange time.
  • 20 FICs per identity. Plan the hub, or split identities per workload.
  • Propagation delay. A fresh FIC can return AADSTS70021: No matching federated identity record found for a few minutes. Add retries to the first exchange.
  • Create FICs sequentially. Parallel writes to the same identity return 409 Conflict. Use a recent azurerm provider, which serialises them.
  • Regional STS endpoint. GetWebIdentityToken isn’t served by the global STS endpoint. Always set a region.
  • RS256 for Azure. Enforce it with the sts:SigningAlgorithm condition.
  • Short tokens. Keep DurationSeconds low for the callback path. Use ≤ 3600 only for the file path, and enforce the ceiling with sts:DurationSeconds.

Troubleshooting

Error Likely cause
AADSTS70021: No matching federated identity record found iss/sub/aud mismatch, or a freshly created FIC still propagating
AADSTS700211: No matching federated identity record found for presented assertion issuer Issuer URL differs, for example a trailing slash or the wrong account
AADSTS700024: Client assertion is not within its valid time range JWT expired. Fetch a fresh one per exchange, or refresh the file more often.
AccessDenied on sts:GetWebIdentityToken Outbound federation not enabled, wrong audience or algorithm in the IAM condition, or an SCP deny
403 AuthorizationPermissionMismatch from Storage Token exchange worked, but the RBAC role assignment is missing or still propagating

Wrap-up

For Azure, federation always comes down to one thing: an OIDC token whose issuer, subject and audience match a federated credential. EKS pods can use the cluster’s issuer with zero code. Everything else on AWS can now get that token from sts:GetWebIdentityToken, with the IAM role ARN as a stable subject. At organisation scale, issue tokens from a single AWS identity account and keep Azure trusting exactly one issuer.

Sources

Comments