Cross-Cloud Federation: Securely Calling Azure Functions from AWS Lambda
In the modern cloud landscape, integrating workloads across providers is a common requirement. Recently, I needed to call an Azure Function App from an AWS Lambda function using only cloud-native, standards-based authentication. This post walks through the entire journey: the foundational concepts, the promising paths that turned into dead ends, the subtle incompatibilities we discovered, and the final, robust, and fully-automated solution.
Understanding the Core Concepts
Before diving into the solution, it’s essential to understand the key technologies that make this possible.
OIDC (OpenID Connect): An identity layer built on top of the OAuth 2.0 framework. Think of it this way: OAuth 2.0 provides authorization (giving an app a key to a specific room), while OIDC provides authentication (providing a verifiable ID card for whoever is holding the key). Its main output is a secure ID Token in JWT format.
Federation: This is the core concept of trust. It allows an identity system in one domain (like Azure AD) to trust identities issued by another system (like AWS Cognito). It’s like airport security in one country trusting the passports issued by another; they don’t need to manage every citizen’s identity, they just need to trust the passport-issuing authority.
AWS IAM (Identity and Access Management): The central service for managing identities and permissions in AWS. For our purposes, the most important primitive is the IAM Role, which provides a temporary, secure identity for a workload (like our Lambda function), rather than a permanent user.
AWS STS (Security Token Service): The AWS service that vends temporary, limited-privilege credentials. When our Lambda needs to prove its identity, it’s STS that generates the necessary tokens, such as through the
GetOpenIdTokenForDeveloperIdentitycall.AWS Cognito: A service with two distinct parts:
- User Pools: A fully-managed user directory for your applications (handling sign-up, sign-in, etc.). It issues standard OIDC ID tokens for human users.
- Identity Pools: A service that can exchange tokens from various providers (including a custom one) for temporary AWS credentials. We use its “Developer Authenticated Identities” feature, where our Lambda uses its IAM Role to ask Cognito to mint an OIDC token on its behalf.
Azure AD (Microsoft Entra ID): Microsoft’s cloud identity service. The key features for us are App Registrations, which define our application’s identity, and Workload Identity Federation, which is the specific feature that allows Azure AD to trust an external token issuer like Cognito.
The Goal
Our objective was simple in principle but required a secure and modern implementation:
- Trigger an Azure Function App from an AWS Lambda.
- Authenticate securely with absolutely no static secrets, API keys, or certificates.
- Use only managed identities and open standards like OIDC and OAuth2.
The Journey: A Tale of Three Failed Attempts
Before arriving at the final solution, we explored several other promising, but ultimately flawed, architectural patterns.
Attempt #1: Direct Federation (AWS IAM Role → Azure AD)
The most elegant pattern seemed to be configuring Azure AD to trust the Lambda’s IAM Role directly.
- The Roadblock: This hit a wall in the Lambda code. The Python ecosystem lacks a simple, built-in function to generate the required OIDC JWT from IAM credentials. Our attempt to use a presigned STS URL was rejected by Azure AD with an
AADSTS50027: JWT token is invaliderror, because a presigned URL is not a JWT.
Attempt #2: KMS Signing (IAM Role → KMS → Azure AD)
To solve the JWT problem, we turned to AWS KMS to securely sign a manually constructed JWT.
- The Roadblock: This failed at the infrastructure level. The
azuread_application_certificateresource expects a full X.509 certificate, but AWS KMS only provides a raw public key, leading to aKeyCredentialsInvalidValueerror. - Impracticality: Furthermore, this pattern adds operational overhead, as the certificate’s validity is defined in our IaC and would require periodic runs to rotate the key in Azure AD.
Attempt #3: Direct Cognito Trust (Cognito → Azure App Service)
Frustrated, we tried to simplify the flow by having the Function App’s own auth middleware trust the Cognito token directly.
- The Roadblock: This also resulted in
401 Unauthorizederrors. We discovered that the JWTs issued by AWS Cognito Identity Pools are designed for AWS federation and are not standard OIDC ID Tokens that Azure’s strict, built-in OIDC middleware is built to consume.
The Breakthrough: Azure AD Workload Identity Federation
With all direct paths blocked, the breakthrough came from reframing the problem. Instead of trying to make Azure’s middleware understand a non-standard AWS token, we used Azure AD’s Workload Identity Federation to explicitly exchange that token for a native, trusted Azure AD token.
How It Works
- Get Token: The AWS Lambda uses its IAM role to ask the Cognito Identity Pool to mint a short-lived OIDC token representing its identity.
- Exchange Token: The Lambda presents this Cognito token as a
client_assertionto the Azure AD/tokenendpoint. Azure AD validates this token against the pre-configured federated credential and returns a standard Azure AD access token. - Call API: The Lambda calls the Azure Function App, presenting the valid Azure AD access token in the
Authorization: Bearerheader.
Lambda Code Example
The final Python code for the Lambda orchestrator is broken into clear, single-responsibility functions:
import os
import requests
import json
import boto3
# Environment variables configured via IaC
AZURE_TENANT_ID = os.environ["AZURE_TENANT_ID"]
AZURE_CLIENT_ID = os.environ["AZURE_CLIENT_ID"]
AZURE_FUNCTION_URL = os.environ["AZURE_FUNCTION_URL"]
AZURE_API_SCOPE = os.environ["AZURE_API_SCOPE"]
COGNITO_IDENTITY_POOL_ID = os.environ["COGNITO_IDENTITY_POOL_ID"]
COGNITO_DEV_PROVIDER_NAME = os.environ["COGNITO_DEV_PROVIDER_NAME"]
def get_cognito_oidc_token(lambda_function_arn):
"""Uses the Lambda's IAM role to get an OIDC token from Cognito."""
cognito_client = boto3.client('cognito-identity')
response = cognito_client.get_open_id_token_for_developer_identity(
IdentityPoolId=COGNITO_IDENTITY_POOL_ID,
Logins={COGNITO_DEV_PROVIDER_NAME: lambda_function_arn},
TokenDuration=900
)
return response['Token']
def exchange_token_for_azure_access_token(cognito_jwt):
"""Exchanges the Cognito OIDC token for a valid Azure AD access token."""
token_endpoint = f"[https://login.microsoftonline.com/](https://login.microsoftonline.com/){AZURE_TENANT_ID}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": AZURE_CLIENT_ID,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": cognito_jwt,
"scope": AZURE_API_SCOPE
}
resp = requests.post(token_endpoint, data=data)
if resp.status_code != 200:
print(f"Azure AD token exchange failed: {resp.status_code} {resp.text}")
raise Exception("Azure AD token exchange failed.")
return resp.json()["access_token"]
def call_azure_function(access_token, email):
"""Calls the target Azure Function with the Azure AD Bearer token."""
headers = {"Authorization": f"Bearer {access_token}"}
params = {"email": email}
resp = requests.get(AZURE_FUNCTION_URL, headers=headers, params=params)
if resp.status_code != 200:
print(f"Azure Function call failed: {resp.status_code} {resp.text}")
raise Exception("Azure Function call failed.")
return resp.json()
def lambda_handler(event, context):
"""Orchestrates the entire cross-cloud authentication and API call."""
email_to_lookup = event.get("email")
if not email_to_lookup:
return {"statusCode": 400, "body": json.dumps({"error": "Email not provided."})}
try:
cognito_jwt = get_cognito_oidc_token(context.invoked_function_arn)
azure_token = exchange_token_for_azure_access_token(cognito_jwt)
result = call_azure_function(azure_token, email_to_lookup)
except Exception as e:
print(f"Error in cross-cloud orchestration: {e}")
return {"statusCode": 500, "body": json.dumps({"error": f"An orchestration error occurred: {str(e)}"}) }
return {
"statusCode": 200,
"body": json.dumps({
"message": "Cross-cloud orchestration successful!",
"retrievedUserData": result
})
}
The Complete Infrastructure as Code (OpenTofu)
Here is the complete OpenTofu configuration to deploy this entire architecture, including the robust, cross-platform build process for the Lambda function.
providers.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.14"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.46"
}
azuread = {
source = "hashicorp/azuread"
version = "~> 3.6"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
provider "aws" {
region = var.aws_region
}
provider "azurerm" {
features {}
}
provider "azuread" {}
variables.tf
variable "aws_region" {
description = "The AWS region to deploy resources."
type = string
default = "us-east-1"
}
variable "app_name_prefix" {
description = "A unique prefix for resource names."
type = string
default = "multicloud"
}
variable "cognito_lambda_identity_id" {
description = "The stable Cognito Identity ID assigned to the Lambda's ARN."
type = string
}
aws.tf
locals {
building_path = "${path.module}/dist/aws_lambda"
lambda_code_filename = "function.zip"
lambda_src_path = "${path.module}/src/aws_lambda"
lambda_src_files = fileset(local.lambda_src_path, "**")
lambda_src_hash = md5(join("", [
for f in local.lambda_src_files : filemd5("${local.lambda_src_path}/${f}")
]))
}
resource "terraform_data" "build_lambda_function" {
triggers_replace = {
build_number = local.lambda_src_hash
}
provisioner "local-exec" {
command = substr(pathexpand("~"), 0, 1) == "/" ? "./py_build.sh \"${local.lambda_src_path}\" \"${local.building_path}\" \"${local.lambda_code_filename}\" Function" : "powershell.exe -File .\\PyBuild.ps1 ${local.lambda_src_path} ${local.building_path} ${local.lambda_code_filename} Function"
}
}
resource "aws_cognito_identity_pool" "main" {
identity_pool_name = "${var.app_name_prefix}-id-pool"
allow_unauthenticated_identities = false
developer_provider_name = "lambda.orchestrator"
}
resource "aws_iam_role" "lambda_exec" {
name = "${var.app_name_prefix}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_policy" "cognito_policy" {
name = "${var.app_name_prefix}-cognito-policy"
description = "Allows Lambda to get a token from the Cognito Identity Pool"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "cognito-identity:GetOpenIdTokenForDeveloperIdentity"
Effect = "Allow"
Resource = aws_cognito_identity_pool.main.arn
}]
})
}
resource "aws_iam_role_policy_attachment" "cognito_policy_attachment" {
role = aws_iam_role.lambda_exec.name
policy_arn = aws_iam_policy.cognito_policy.arn
}
resource "aws_iam_role_policy_attachment" "lambda_policy" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_lambda_function" "orchestrator" {
function_name = "${var.app_name_prefix}-orchestrator"
role = aws_iam_role.lambda_exec.arn
handler = "index.lambda_handler"
runtime = "python3.13"
architectures = ["arm64"]
filename = "${local.building_path}/${local.lambda_code_filename}"
source_code_hash = filebase64sha256("${local.building_path}/${local.lambda_code_filename}")
depends_on = [terraform_data.build_lambda_function]
environment {
variables = {
AZURE_TENANT_ID = data.azurerm_client_config.current.tenant_id
AZURE_CLIENT_ID = azuread_application.client_app.client_id
AZURE_API_SCOPE = "${azuread_application.api_app.api.oauth2_permission_scopes[0].value}/.default"
AZURE_FUNCTION_URL = "https://${azurerm_linux_function_app.main.default_hostname}/api/GetUserID"
COGNITO_IDENTITY_POOL_ID = aws_cognito_identity_pool.main.id
COGNITO_DEV_PROVIDER_NAME = aws_cognito_identity_pool.main.developer_provider_name
}
}
}
azure.tf
data "azurerm_client_config" "current" {}
resource "random_string" "suffix" {
length = 8
special = false
upper = false
}
resource "azurerm_resource_group" "main" {
name = "${var.app_name_prefix}-rg-${random_string.suffix.result}"
location = "East US"
}
# ... (azurerm_storage_account, azurerm_service_plan, data.archive_file for azure function)
resource "azuread_application" "api_app" {
display_name = "${var.app_name_prefix}-api-function"
api {
oauth2_permission_scope {
admin_consent_description = "Allow the application to access the user data API."
admin_consent_display_name = "Access User Data API"
enabled = true
id = random_uuid.api_scope_id.result
type = "User"
value = "data.read"
}
}
}
resource "random_uuid" "api_scope_id" {}
resource "azuread_service_principal" "api_sp" {
client_id = azuread_application.api_app.client_id
}
resource "azuread_application" "client_app" {
display_name = "${var.app_name_prefix}-lambda-client"
required_resource_access {
resource_app_id = azuread_application.api_app.id
resource_access {
id = azuread_application.api_app.api.oauth2_permission_scopes[0].id
type = "Scope"
}
}
}
resource "azuread_application_federated_identity_credential" "main" {
application_object_id = azuread_application.client_app.object_id
display_name = "cognito-identity-pool-federation"
audiences = [aws_cognito_identity_pool.main.id]
issuer = "https://cognito-identity.${var.aws_region}.amazonaws.com"
subject = var.cognito_lambda_identity_id
}
resource "azurerm_linux_function_app" "main" {
name = "${var.app_name_prefix}-func-${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
# ...
auth_settings_v2 {
auth_enabled = true
unauthenticated_action = "Return401"
login {
token_store_enabled = true
}
active_directory_v2 {
client_id = azuread_application.api_app.client_id
tenant_auth_endpoint = "[https://login.microsoftonline.com/$](https://login.microsoftonline.com/$){data.azurerm_client_config.current.tenant_id}/v2.0"
}
}
}
Key Lessons Learned
- Token Compatibility is Key: Not all OIDC tokens are created equal. Azure App Service’s built-in middleware is strict; an intermediary exchange via Azure AD’s token endpoint is more robust.
- Embrace Workload Identity Federation: This Azure AD feature is the modern, correct pattern for secret-less, cross-cloud authentication from external identity providers.
Why This Solution Is Best
- Zero Secrets: No static keys, certificates, or secrets are stored in Lambda or Azure.
- It Works: It avoids the IaC and token format incompatibilities that plagued the other approaches.
- Cloud-Native and Standards-Based: The entire solution uses OIDC, OAuth2, and managed identities.
- Fully Automated: With OpenTofu, the entire infrastructure, including the Lambda build process, is managed as code.
Conclusion
Cross-cloud integration doesn’t have to mean insecure hacks. After navigating several technical dead ends, we found that leveraging Azure AD’s workload identity federation with AWS Cognito as a token source provides a robust, secure, and maintainable multi-cloud workflow. With the right pattern, you can build systems that securely leverage the best of every cloud.
References