Tutorial: Azure Federation
A Defakto-issued JSON Web Token-SPIFFE Verifiable Identity Document (JWT-SVID) can be used to authenticate to Microsoft APIs and access Azure or other resources. This is done by setting up an Azure User-Assigned Managed Identity with Workload Identity Federation that supports OpenID Connect (OIDC). Defakto makes this process easier by automatically providing an OIDC Discovery document endpoint for your trust domain(s).
Preconditions:
- Active SPIFFE trust domain (e.g. "example.com")
- Existing Kubernetes cluster
- Azure permission to create a User-Assigned Managed Identity and Federated Identity Credential
- Azure subscription with permissions to create a Storage Account and role assignments
- Azure CLI (
az) installed and authenticated
What this tutorial creates in Azure:
- A resource group and Storage Account with a blob container holding a test file
- A User-Assigned Managed Identity
- A Federated Identity Credential on that identity, scoped to your workload's SPIFFE ID
This tutorial will demonstrate using a combination of the Azure portal and the command-line, but deploying the necessary infrastructure can also be performed with tools like Terraform, Bicep, or Azure Resource Manager (ARM) templates.
1. Create a Storage Account and upload test file
- Create a text file on your local computer named
test.txtcontaining the following:SPIFFE-to-Azure authentication succeeded! - Set environment variables for your Azure subscription and desired resource names:
export AZURE_SUBSCRIPTION_ID="your-subscription-id"export AZURE_RESOURCE_GROUP="defakto-test-rg"export AZURE_LOCATION="eastus"export STORAGE_ACCOUNT_NAME="defaktotest$(date +%s)"# Holds the blob container and test file created later in this sectionexport CONTAINER_NAME="test-container"
- Create a resource group:
az group create \--name ${AZURE_RESOURCE_GROUP} \--location ${AZURE_LOCATION}
- Create a storage account:
az storage account create \--name ${STORAGE_ACCOUNT_NAME} \--resource-group ${AZURE_RESOURCE_GROUP} \--location ${AZURE_LOCATION} \--sku Standard_LRS \--min-tls-version TLS1_2
- Grant yourself data plane access to the storage account:
export CURRENT_USER=$(az ad signed-in-user show --query id --output tsv)az role assignment create \--assignee ${CURRENT_USER} \--role "Storage Blob Data Contributor" \--scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT_NAME}"note
The "Owner" role provides control plane (management) access to Azure resources but does not grant data plane access. To read and write blobs, you need an explicit data plane role like "Storage Blob Data Contributor". This role assignment allows you to upload the test file in the next steps.
importantRole assignments can take 2-5 minutes to propagate. Wait a few minutes before proceeding to the next step. You can verify the role assignment is active by running:
az role assignment list \--assignee ${CURRENT_USER} \--scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT_NAME}" \--query "[?roleDefinitionName=='Storage Blob Data Contributor']"If you see an empty array
[], wait another minute and try again. - Create a blob container:
az storage container create \--name ${CONTAINER_NAME} \--account-name ${STORAGE_ACCOUNT_NAME} \--auth-mode login
- Upload the test file to the container:
az storage blob upload \--account-name ${STORAGE_ACCOUNT_NAME} \--container-name ${CONTAINER_NAME} \--name test.txt \--file test.txt \--auth-mode login
2. Determine the OIDC Discovery Endpoint
Defakto automatically publishes an OIDC Discovery document for all trust domains.
- Ensure that
spirlctlis installed., and usespirlctl loginto log in via SSO. - Run
spirlctl trust-domain info <TRUST_DOMAIN>to find the right URI for your trust domain. For example, here is the output for a demonstration trust domain:$ spirlctl trust-domain info defakto.example.comGetting Trust Domain Info⠼ID td-wyw33falztName: defakto.example.comStatus: availableSelf-Managed: falseSPIRL Agent Endpoint: td-wyw33falzt.agent.spirl.com:443SPIFFE Bundle Endpoint: https://fed.spirl.org/t-o9cpowm5yo/td-wyw33falzt/bundleJWT Issuer: https://fed.spirl.org/t-o9cpowm5yo/td-wyw33falztJWKS Endpoint: https://fed.spirl.org/t-o9cpowm5yo/td-wyw33falzt/jwksOIDC Discovery Endpoint: https://fed.spirl.org/t-o9cpowm5yo/td-wyw33falzt/.well-known/openid-configurationCreated At: 2024-09-05 00:41:36.716 +0000 UTCLast Updated At: 2025-07-07 15:15:14.718 +0000 UTC - Copy the URI for JWT Issuer and save in a file named
jwt-issuer.txt. Make sure it does not end with/.well-known/openid-configuration.
You can also find these values in the console. Open the trust domain, click View URLs, then go to Settings > Trust domain URLs to see the JWT Issuer and OIDC Discovery Endpoint.
3. Create a User-Assigned Managed Identity
Azure User-Assigned Managed Identities represent workloads as first-class Azure resources. They support Federated Identity Credentials, scale to more federations than App Registrations, and the permission to create one is more commonly granted than the permission to create an App Registration. We'll create a Managed Identity that will trust tokens from the Defakto trust domain.
- Set environment variables for the Managed Identity name:
export MI_NAME="defakto-federation-mi"
- Create the Managed Identity and capture its Client ID and Principal ID:
az identity create \--name ${MI_NAME} \--resource-group ${AZURE_RESOURCE_GROUP} \--location ${AZURE_LOCATION}export MI_CLIENT_ID=$(az identity show \--name ${MI_NAME} \--resource-group ${AZURE_RESOURCE_GROUP} \--query clientId \--output tsv)export MI_PRINCIPAL_ID=$(az identity show \--name ${MI_NAME} \--resource-group ${AZURE_RESOURCE_GROUP} \--query principalId \--output tsv)echo "Managed Identity Client ID: ${MI_CLIENT_ID}"echo "Managed Identity Principal ID: ${MI_PRINCIPAL_ID}"echo ${MI_CLIENT_ID} > mi-client-id.txtecho ${MI_PRINCIPAL_ID} > mi-principal-id.txtnote
The Client ID is what your workload presents to Azure AD when exchanging a JWT-SVID for an access token. The Principal ID is what Azure RBAC uses to grant the Managed Identity permissions on resources like the storage account in the next step.
4. Grant Storage access to the Managed Identity
Now we'll grant the Managed Identity permission to access the storage account using Azure RBAC (Role-Based Access Control).
A newly created Managed Identity's service principal can take up to a minute to replicate into Entra ID. If the next command fails with "Cannot find user or service principal in graph database", wait 30-60 seconds and retry.
- Assign the "Storage Blob Data Reader" role to the Managed Identity:
az role assignment create \--assignee ${MI_PRINCIPAL_ID} \--role "Storage Blob Data Reader" \--scope "/subscriptions/${AZURE_SUBSCRIPTION_ID}/resourceGroups/${AZURE_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT_NAME}"
- Wait a few moments for the role assignment to propagate (this can take 1-2 minutes).
5. Deploy the Azure JWT-SVID demo workload
We'll deploy a small workload from the Defakto Examples
repo
that fetches a JWT-SVID with the Azure-required audience, logs its own SPIFFE
ID, and retries the Azure AD token exchange and test file download every 15
seconds until it succeeds. Unlike using
spirldbg, which requires
copying a token out to a local shell, this workload authenticates to Azure
itself.
- Ensure that
spirlctlis installed.. - Bootstrap an existing Kubernetes cluster (e.g. "azure-demo") with Defakto:
spirlctl cluster add "azure-demo" --trust-domain "example.com" --platform k8s
- Create the namespace for the demo workload:
kubectl create namespace azure-federation-demo --dry-run=client -o yaml | kubectl apply -f -
- Retrieve your Azure tenant ID:
export AZURE_TENANT_ID=$(az account show --query tenantId --output tsv)
- Download the example, substitute your Azure values, and deploy it:
curl -sL https://raw.githubusercontent.com/spirl/defakto-examples/main/workloads/azure-jwt-svid-demo/deployment.yaml \| sed -e "s/<AZURE_TENANT_ID>/${AZURE_TENANT_ID}/" \-e "s/<MI_CLIENT_ID>/${MI_CLIENT_ID}/" \-e "s/<STORAGE_ACCOUNT_NAME>/${STORAGE_ACCOUNT_NAME}/" \-e "s/<CONTAINER_NAME>/${CONTAINER_NAME}/" \| kubectl apply -n azure-federation-demo -f -
- Watch the pod logs to find the workload's SPIFFE ID:
kubectl logs -n azure-federation-demo deployment/azure-jwt-svid-demo2026-07-17T17:31:40Z Fetching JWT-SVID for Azure federation (audience: api://AzureADTokenExchange)...2026-07-17T17:31:40Z Workload SPIFFE ID: spiffe://defakto.example.com/azure-demo/ns/azure-federation-demo/sa/default2026-07-17T17:31:40Z Create the Azure federated credential with this SPIFFE ID as the subject.2026-07-17T17:31:40Z This workload will retry the Azure login every 15 seconds until the credential is ready.2026-07-17T17:31:41Z Azure AD token exchange failed. Response:{"error":"invalid_client","error_description":"AADSTS70025: The client '00000000-0000-0000-0000-000000000000'(defakto-federation-mi) has no configured federated identity credentials. Trace ID: 12721626-015f-4143-881b-c0d3dca88000 Correlation ID: 0556bb37-02fa-458a-acec-b5744a9486be Timestamp: 2026-07-17 17:31:41Z","error_codes":[70025],"timestamp":"2026-07-17 17:31:41Z","trace_id":"12721626-015f-4143-881b-c0d3dca88000","correlation_id":"0556bb37-02fa-458a-acec-b5744a9486be"}2026-07-17T17:31:41Z Not ready yet, retrying in 15s...important
The workload requests its JWT-SVID with
--audience api://AzureADTokenExchange. Azure requires this specific audience value to match the federated credential configuration. Without it, the token exchange will fail with error "AADSTS700212: No matching federated identity record found." - Copy the SPIFFE ID from the log output for the next step:
export SPIFFE_ID="spiffe://defakto.example.com/kind-azure-demo/ns/azure-federation-demo/sa/default"
The pod will keep printing "Not ready yet, retrying in 15s..." until you create the federated credential in the next step.
6. Create the federated credential with the SPIFFE ID
Now that we have the workload's SPIFFE ID from step 5, we can create a federated credential on the Managed Identity.
- Add the federated credential to the Managed Identity:
az identity federated-credential create \--name "defakto-federation-credential" \--identity-name ${MI_NAME} \--resource-group ${AZURE_RESOURCE_GROUP} \--issuer "$(cat jwt-issuer.txt)" \--subject "${SPIFFE_ID}" \--audiences "api://AzureADTokenExchange"
- Wait a few moments for the change to propagate (typically 30-60 seconds). The workload retries every 15 seconds, so it will pick up the credential automatically.
Now the Managed Identity will verify the issuer, audience, and require the specific SPIFFE ID. This provides strong identity-based authentication from your workload to Azure.
For production environments with multiple workloads, you can create multiple federated credentials on the same Managed Identity, each with a different SPIFFE ID. Alternatively, you can create separate Managed Identities for different workload groups. Consult the Azure documentation for more information.
7. Verify Azure Storage access
The workload is already retrying the Azure AD token exchange and test file download every 15 seconds. Watch the logs again to see it succeed once the federated credential has propagated:
kubectl logs -f -n azure-federation-demo deployment/azure-jwt-svid-demo
You should see SPIFFE-to-Azure authentication succeeded! in the output. See
the "Troubleshooting" section if you encounter any errors.
Troubleshooting
Authentication Failed Error
If you get an "Authentication Failed" error when trying to access the Storage Account, verify that:
- The Managed Identity has the correct role assignment on the storage account
- The federated credential has the correct issuer and audience configured
- The JWT token includes the expected audience value (
api://AzureADTokenExchange) - The role assignment has had time to propagate (can take 1-2 minutes)
You can request a fresh JWT-SVID directly from the pod to inspect its claims:
kubectl exec -n azure-federation-demo deployment/azure-jwt-svid-demo -- \
sh -c 'spirldbg svid-jwt --audience api://AzureADTokenExchange --filename /tmp/debug.jwt && cat /tmp/debug.jwt'
Paste the output into the tool at https://jwt.io to verify the claims.
Invalid Client Error
If you encounter an "invalid_client" error when exchanging the JWT for an access token, verify that:
- The Managed Identity exists and the Client ID is correct
- The federated credential is properly configured with the correct issuer
- The
subclaim in the JWT matches thesubjectfield in the federated credential - The
audclaim includes a value that matches the one specified in the federated credential
You can list the federated credentials for your Managed Identity:
az identity federated-credential list \
--identity-name ${MI_NAME} \
--resource-group ${AZURE_RESOURCE_GROUP}
Token Expired Error
The demo workload fetches a fresh JWT-SVID on every retry, so token expiration shouldn't affect it. If you're inspecting a token manually (see "Authentication Failed Error" above) and it has expired, retrieve a new one the same way.
You can decode a token by pasting it in the tool at https://jwt.io. There you
can check the token expiration time by hovering your mouse pointer over the exp
field.
In a production environment, you will need a way to automatically refresh the JWT tokens for access to Azure. SPIFFE libraries are available for a number of popular languages.
Subject Mismatch Error
Azure's federated credentials match the subject field against the JWT's sub
claim. If these don't match, authentication will fail. Common issues:
- The
subjectused when creating the federated credential (step 6) doesn't exactly match the SPIFFE ID retrieved in step 5 - The JWT's
subclaim contains the full SPIFFE ID, including the trust domain and workload path - If you redeploy the workload, its SPIFFE ID may change. Retrieve the new SPIFFE ID and update the federated credential's subject to match
Use the full SPIFFE ID as the subject, as shown in step 6.
Storage Access Denied
If the token exchange succeeds but you cannot access the storage blob, verify:
- The role assignment was created successfully:
az role assignment list --assignee ${MI_PRINCIPAL_ID} - The role assignment scope includes the storage account
- You're using the correct storage account name and container name
- The blob exists:
az storage blob list --account-name ${STORAGE_ACCOUNT_NAME} --container-name ${CONTAINER_NAME} --auth-mode login