Skip to content
Secretless Delivery: GitHub Actions, Entra Workload Identity, and AKS

Secretless Delivery: GitHub Actions, Entra Workload Identity, and AKS

in

Why Are We Still Solving This Wrong?

I'll be honest; I've been that person who had a Service Principal with Contributor on a subscription sitting in a GitHub secret for way longer than I'd like to admit. I set it up during a late night deployment push, it worked, and I told myself I'd come back and fix it properly; boy, that "fix it later" turned into about eighteen months of pretending the problem didn't exist. The SP had no expiry alert, no rotation schedule, and I forgot which workflows were even using it until one day it expired and three pipelines broke at once. That's basically the problem with static credentials in a nutshell; they work until they don't, and by then you've got a mess on your hands.

I've watched teams deploying to Azure Kubernetes Service from GitHub Actions do it exactly this way ; a Service Principal stored as a GitHub secret, a Personal Access Token, a storage account key; maybe they've rotated them once in the past two years, maybe not. This is 2026 and secretless deployment is mature, supported by every major Azure SDK, and solves the chronic pain of credential management; yet it remains a hard sell, not since it's complicated but since it requires coordinating trust across three systems and understanding why that coordination matters.

If you're tired of credential management and willing to invest in getting it right, this is how.

What Secretless Delivery Actually Means

graph LR
    GH["GitHub Actions\nOIDC Token"] -->|Federated Credential| ENTRA["Entra ID\nApp Registration"]
    ENTRA -->|Workload Identity| AKS["AKS Pod\nService Account"]
    AKS -->|Managed Identity| KV["Azure Key Vault"]

Secretless doesn't mean "no secrets;" it means no long-lived credentials in GitHub. Instead, GitHub Actions obtains short-lived tokens at runtime using OpenID Connect (OIDC). These tokens are issued by your Entra ID tenant and are cryptographically tied to a specific workflow, repository, and branch; they expire in minutes, not hours, not days.

For me the real shift was this: instead of GitHub storing a credential and using it to request access, Entra ID is directly aware that "this specific GitHub Actions workflow in this specific repository is requesting access," which means there's nothing to leak, nothing to rotate, and nothing that works outside the exact context it was issued for.

The architecture involves four trust relationships:

  1. GitHub → Entra ID: GitHub's OIDC provider is registered with Entra ID. When a workflow runs, GitHub issues a JWT signed with its private key.
  2. Entra ID → Federated Credential: Your Entra application has a federated credential that accepts JWTs from GitHub's OIDC provider, but only for specific repository/branch/environment combinations (this is where the real policy enforcement lives).
  3. Entra ID → AKS: AKS uses workload identity, where a pod can assume an Entra application identity.
  4. Pod → Key Vault: The pod uses its workload identity to request secrets without storing credentials (no static connection strings, no config files with embedded keys).

None of these involve static credentials, and every single one is time-bound and cryptographically verified, which is the whole point.

Why This Matters

Let me put this in perspective with a quick comparison:

Aspect Static Credential (SP Secret) OIDC Token
Blast radius Full scope of the SP's RBAC Single workflow run
Rotation needed Yes, manually or via automation No, tokens are ephemeral
Audit trail Limited; you know the SP was used Full; GitHub run ID, repo, branch, environment
Lifetime Months to years (let's be honest) Minutes (duration of the workflow run)
Leakage impact Attacker has persistent access Token is expired before they can use it

Here's what it comes down to: a leaked GitHub secret works from anywhere until someone rotates it, and an attacker can use it for anything the credential is scoped to. A leaked OIDC token expires in minutes, only works within a specific workflow run, and Entra ID logs exactly which workflow issued it; an attacker would need to be in the middle of the GitHub Actions runtime to even intercept it.

That being said, this doesn't mean "set it and forget it." You still need audit logging, least-privilege RBAC, network policies, and secrets management hygiene; OIDC is a layer, not a complete solution.

Implementation: Start to Finish

Alright, let's set it up; the whole process takes about an hour if you know what you're doing.

Prerequisites

  • GitHub.com (OIDC provider is available)
  • Azure subscription with Entra ID
  • AKS cluster (1.27+)
  • az cli, kubectl

Step 1: Register an Entra Application

This is the identity your GitHub Actions workflow will assume ; the starting point for the whole trust chain.

GITHUB_APP_NAME="github-actions-deployer"
TENANT_ID=$(az account show --query tenantId -o tsv)
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

az ad app create --display-name $GITHUB_APP_NAME \
  --query appId -o tsv > app_id.txt

APP_ID=$(cat app_id.txt)
echo "Created app: $APP_ID"

Step 2: Create a Service Principal

az ad sp create --id $APP_ID
SP_OBJECT_ID=$(az ad sp show --id $APP_ID --query id -o tsv)
echo "Service principal object ID: $SP_OBJECT_ID"

Step 3: Add a Federated Credential

This is where you enforce policy. The credential only accepts tokens from a specific GitHub repository and branch; a token minted for your dev branch can't be used to deploy to production, and Entra ID will simply reject it.

REPO_OWNER="myorg"
REPO_NAME="myrepo"
ENVIRONMENT="production"

az ad app federated-credential create \
  --id $APP_ID \
  --parameters '{
    "name": "github-actions-'"$REPO_NAME"'-'"$ENVIRONMENT"'",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:'"$REPO_OWNER"'/'"$REPO_NAME"':environment:'"$ENVIRONMENT"'",
    "audiences": ["api://AzureADTokenExchange"],
    "description": "Federated credential for GitHub Actions deployment"
  }'
Entra ID federated credential configured for GitHub Actions OIDC

The subject patterns you can use:

  • repo:owner/repo:ref:refs/heads/main (specific branch)
  • repo:owner/repo:environment:production (GitHub environment)
  • repo:owner/repo:pull_request (PR runs, which I wouldn't recommend for deployments)

For production deployments, use GitHub environments ; they can have their own protection rules.

Step 4: Grant RBAC Permissions

az role assignment create \
  --role "Contributor" \
  --assignee-object-id $SP_OBJECT_ID \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/my-aks-rg"

Narrow this further if possible; many teams can use Azure Kubernetes Service Cluster User Role instead of Contributor. Actually, most teams should; Contributor is always more than you need for just pushing deployments, and I've seen this go wrong more times than I'd like to admit.

Step 5: Enable AKS Workload Identity

For new clusters:

az aks create \
  --resource-group my-aks-rg \
  --name my-aks-cluster \
  --enable-oidc-issuer \
  --enable-workload-identity \
  ...other flags...

For existing clusters:

az aks update \
  --resource-group my-aks-rg \
  --name my-aks-cluster \
  --enable-oidc-issuer \
  --enable-workload-identity

OIDC_URL=$(az aks show \
  --resource-group my-aks-rg \
  --name my-aks-cluster \
  --query "oidcIssuerProfile.issuerUrl" -o tsv)

echo "OIDC URL: $OIDC_URL"
AKS cluster overview showing OIDC issuer, Cilium dataplane, and workload identity

Step 6: Create AKS Workload Identity Federated Credential

You now need a second Entra application for the AKS pod itself (yes, a second one; I know, I know), so you'll have one identity for "GitHub deploying things" and another for "the running pod accessing secrets." Separate concerns, separate blast radii.

AKS_APP_NAME="aks-app-identity"
az ad app create --display-name $AKS_APP_NAME \
  --query appId -o tsv > aks_app_id.txt

AKS_APP_ID=$(cat aks_app_id.txt)
az ad sp create --id $AKS_APP_ID
AKS_SP_OBJECT_ID=$(az ad sp show --id $AKS_APP_ID --query id -o tsv)

NAMESPACE="default"
SERVICE_ACCOUNT="my-app"

az ad app federated-credential create \
  --id $AKS_APP_ID \
  --parameters '{
    "name": "aks-wi-'"$NAMESPACE"'-'"$SERVICE_ACCOUNT"'",
    "issuer": "'"$OIDC_URL"'",
    "subject": "system:serviceaccount:'"$NAMESPACE"':'"$SERVICE_ACCOUNT"'",
    "audiences": ["api://AzureADTokenExchange"],
    "description": "Workload identity for AKS pod"
  }'

Step 7: Annotate the Kubernetes Service Account

kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-app
  namespace: default
  annotations:
    azure.workload.identity/client-id: $AKS_APP_ID
EOF

Step 8: Deploy a Pod Using Workload Identity

apiVersion: v1
kind: Pod
metadata:
  name: my-app-pod
  namespace: default
  labels:
    azure.workload.identity/use: "true"
spec:
  serviceAccountName: my-app
  containers:
  - name: app
    image: myregistry.azurecr.io/myapp:latest
    # Note: AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_AUTHORITY_HOST
    # are automatically injected by the workload identity webhook
    # when the azure.workload.identity/use label is set to "true".
    # You don't need to set them manually.

Step 9: Grant Key Vault Permissions

KEY_VAULT_ID="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/my-aks-rg/providers/Microsoft.KeyVault/vaults/my-vault"

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $AKS_SP_OBJECT_ID \
  --scope $KEY_VAULT_ID
Key Vault IAM showing role assignments including Key Vault Secrets User

How Does the GitHub Actions Workflow Look?

Now wire it all together ; here's a complete working example:

name: Deploy to AKS

on:
  push:
    branches:
      - main

env:
  REGISTRY: myregistry.azurecr.io
  IMAGE_NAME: myapp

permissions:
  contents: read
  id-token: write  # Critical: allows OIDC token generation

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # GitHub enforces protection rules here

    steps:
      - uses: actions/checkout@v4

      - name: Log in with Azure CLI using OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Build and push container image
        run: |
          az acr build \
            --registry $(echo $REGISTRY | cut -d. -f1) \
            --image ${{ env.IMAGE_NAME }}:${{ github.sha }} \
            --image ${{ env.IMAGE_NAME }}:latest \
            -f Dockerfile .

      - name: Update kubeconfig
        run: |
          az aks get-credentials \
            --resource-group my-aks-rg \
            --name my-aks-cluster \
            --overwrite-existing

      - name: Deploy to AKS
        run: |
          kubectl set image deployment/my-app \
            my-app=$REGISTRY/$IMAGE_NAME:${{ github.sha }} \
            -n default

          kubectl rollout status deployment/my-app -n default

Pay attention to permissions.id-token: write and the environment field; the first one is essential because without it GitHub won't generate an OIDC token, and the second is where GitHub enforces protection rules. azure/login@v2 exchanges the OIDC token for an Azure access token automatically. AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID are stored as secrets but they're just IDs, not sensitive; you could even commit them to the repo and you probably should; it makes onboarding easier.

The Application Code Side

Both major SDK families work the same way. In Python with azure-identity and azure-keyvault-secrets:

from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

credential = DefaultAzureCredential()
client = SecretClient(
    vault_url="https://my-vault.vault.azure.net/",
    credential=credential
)

secret = client.get_secret("my-secret-name")
print(secret.value)

And in C# with Azure.Identity and Azure.Security.KeyVault.Secrets:

using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

var credential = new DefaultAzureCredential();
var client = new SecretClient(
    new Uri("https://my-vault.vault.azure.net/"),
    credential
);

var secret = await client.GetSecretAsync("my-secret-name");
Console.WriteLine(secret.Value.Value);

Both SDKs automatically detect workload identity via DefaultAzureCredential, so no extra configuration is needed beyond the Kubernetes annotations and Entra federated credentials; it just works, and that's one of the nicer things about the Azure SDK.

How Do You Migrate From Static Credentials?

If you have existing deployments with stored credentials, migrating to OIDC is lower-risk than you'd think. I've walked three different teams through this now, and the pattern is pretty much the same every time.

Phase 1: Parallel Setup (No Traffic Impact) ; roughly 30 minutes.

  1. Create a new Entra app for GitHub Actions OIDC
  2. Register federated credentials
  3. Grant RBAC permissions
  4. Do not remove old credentials yet

Phase 2: Update Workflow (Limited Risk) ; roughly an hour.

  1. Update the GitHub Actions workflow to use OIDC
  2. Run a few manual workflows to verify
  3. Commit updated workflow to a feature branch and test with a PR

Phase 3: Validation (Monitoring) ; give it a day.

  1. Merge workflow changes to main
  2. Monitor Azure Activity Log and Application Insights for new deployment runs
  3. Verify logs show authentication via the new service principal

Phase 4: Cleanup (Safe Removal)

Once you're confident the new workflow is stable:

gh secret delete AZURE_CREDENTIALS
az ad sp delete --id <old-sp-id>

Rollback Strategy: If the new OIDC setup fails, the old static credential still works; rollback is immediate, you just revert the workflow change and redeploy using the old credential. That's the beauty of doing it in phases.

What Goes Wrong? Troubleshooting Common Issues

OIDC Token Exchange Fails: Verify permissions.id-token: write in the workflow, check that the GitHub environment (if used) is defined in repo settings, and ensure the runner can reach token.actions.githubusercontent.com. Also confirm that the Entra application ID in your workflow secrets matches the app with the federated credential; a mismatch here produces a generic "AADSTS" error that doesn't tell you what's actually wrong.

Federated Credential Subject Mismatch: The sub claim in the JWT must exactly match your federated credential pattern, which is the number one thing that trips people up; boy, it's frustrating when it happens. Common patterns:

  • repo:owner/repo:ref:refs/heads/main (specific branch, exact match only, no wildcards)
  • repo:owner/repo:environment:production (environment)

Debug by decoding the JWT from your workflow run and comparing the sub claim to your federated credential; a single character off and Entra just says "no."

Pod Can't Access Key Vault: Verify the service account has the correct annotation, then verify the AKS app's federated credential uses the correct issuer and subject, and finally verify the AKS app has the correct role assignment on Key Vault.

kubectl get sa my-app -o yaml | grep azure.workload.identity
az ad app federated-credential list --id $AKS_APP_ID
az role assignment list --assignee-object-id $AKS_SP_OBJECT_ID

Security: Threat Boundaries and Least Privilege

Where's the Network Trust Boundary?

Data flows across several boundaries here. GitHub and Entra are Microsoft-managed, so the part you actually own is the pod-to-Entra boundary.

Restrict pod egress to only necessary endpoints:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: isolate-pod-egress
  namespace: default
spec:
  podSelector:
    matchLabels:
      app: my-app
  policyTypes:
  - Egress
  egress:
  - to:
    - podSelector: {}
  - to:
    - namespaceSelector:
        matchLabels:
          name: kube-system
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
    ports:
    - protocol: TCP
      port: 443

How Tight Should RBAC Be?

Your Entra app for GitHub Actions has RBAC role assignments that define what it can do once authenticated.

Configuration risk: If your GitHub Actions app has Contributor role on a subscription, a compromised workflow can modify anything. Whatever you do, don't do this; I've seen it in the wild more times than I'd like.

Mitigation: Narrow RBAC to the minimum:

# Instead of Contributor on the subscription:
az role assignment create \
  --role "Contributor" \
  --assignee-object-id $SP_OBJECT_ID \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/aks-only-rg"

# Or use custom roles with specific actions

For multiple services needing different permissions, create multiple Entra apps:

# App for AKS deployments
az ad app create --display-name "github-aks-deployer"

# App for database migrations
az ad app create --display-name "github-db-migrator"

# App for storage operations
az ad app create --display-name "github-storage-operator"

# In workflow, use different apps per step

Attack Vector: Exfiltrate Secrets from Key Vault

Mitigation:

  1. Scope Key Vault access to specific secrets using Azure RBAC instead of vault access policies. RBAC lets you assign permissions at the individual secret level:
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee $SP_OBJECT_ID \
  --scope /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG/providers/Microsoft.KeyVault/vaults/prod-vault/secrets/db-password

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee $SP_OBJECT_ID \
  --scope /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG/providers/Microsoft.KeyVault/vaults/prod-vault/secrets/api-key
  1. Audit every secret access:
az monitor diagnostic-settings create \
  --name "keyvault-audit" \
  --resource /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG/providers/Microsoft.KeyVault/vaults/$VAULT_NAME \
  --logs '[
    {
      "category": "AuditEvent",
      "enabled": true
    }
  ]' \
  --workspace /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG/providers/microsoft.operationalinsights/workspaces/$WORKSPACE_NAME

Attack Vector: Deploy Malicious Code

Mitigation:

  1. Use GitOps for deployments (Flux, ArgoCD). Workflows don't directly invoke kubectl apply; they commit to Git, and ArgoCD reconciles.
- name: Update deployment manifest
  run: |
    sed -i "s|my-app:.*|my-app:${{ github.sha }}|g" k8s/deployment.yaml
    git config user.email "[email protected]"
    git config user.name "CI Bot"
    git add k8s/deployment.yaml
    git commit -m "Deploy ${{ github.sha }}"
    git push
  1. Enforce image signing (Azure Container Registry with Ratify for admission verification).
  2. Limit workflow permissions: permissions.contents: read prevents workflows from writing to the repository.

Multi-Environment Setup with Least Privilege

For dev, staging, and production, use different Entra apps with increasingly narrow permissions:

REPO="payment-service"
ENVIRONMENTS=(dev staging production)

for ENV in "${ENVIRONMENTS[@]}"; do
  APP_NAME="github-$REPO-$ENV"
  APP_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)
  az ad sp create --id $APP_ID
  SP_OBJECT_ID=$(az ad sp show --id $APP_ID --query id -o tsv)

  az ad app federated-credential create \
    --id $APP_ID \
    --parameters '{
      "name": "github-'"$ENV"'",
      "issuer": "https://token.actions.githubusercontent.com",
      "subject": "repo:myorg/'"$REPO"':environment:'"$ENV"'",
      "audiences": ["api://AzureADTokenExchange"]
    }'

  # Dev: broad permissions
  if [ "$ENV" == "dev" ]; then
    az role assignment create \
      --role "Contributor" \
      --assignee-object-id $SP_OBJECT_ID \
      --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/dev-rg"
  fi

  # Production: minimal permissions
  if [ "$ENV" == "production" ]; then
    az role assignment create \
      --role "Reader" \
      --assignee-object-id $SP_OBJECT_ID \
      --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/prod-rg"
  fi
done

In your workflow:

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID_PROD }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID_PROD }}

Audit and Compliance

OIDC creates an audit trail at every layer, so use it.

GitHub Actions Audit Log:

gh api repos/myorg/myrepo/actions/runs \
  --paginate \
  --jq '.workflow_runs[] | {id, name, conclusion, created_at, actor: .actor.login}' \
  > github-runs.json

Entra Sign-In Logs (via Microsoft Graph):

az rest --method GET \
  --url "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=appId eq '$APP_ID'&\$top=50" \
  --query "value[].{Time: createdDateTime, Status: status.errorCode, App: appDisplayName}" \
  -o table

Note: Entra sign-in logs aren't in the Azure Activity Log, which is a common mistake; you need Microsoft Graph or the Entra admin center.

Key Vault Access Logs:

Build an automated query that correlates GitHub runs with Azure actions and Key Vault access, and store audit packets in immutable storage (Azure Blob Storage with legal hold); retention should match compliance requirements, which is typically 1 to 3 years.

The Short Version

If you take nothing else from this article:

  1. Use GitHub Environments with protection rules; nope, not optional.
  2. Exact subject matches only ; wildcards aren't supported in federated credentials; one character off and the exchange just fails silently.
  3. Least privilege RBAC on everything; Contributor on a subscription is never the answer.
  4. Separate Entra apps per service ; blast radius matters more than convenience.
  5. DefaultAzureCredential() in your application code; it just works across workload identity, managed identity, and local dev.
  6. Quarterly review of your OIDC setup; orphaned federated credentials are tech debt you don't want.

Wrapping Up

I for one have migrated every project I manage to OIDC at this point and I sleep better knowing there isn't a stale Service Principal floating around in some GitHub secret waiting to expire or leak. The setup takes an afternoon, the migration is low-risk if you do it in phases, and once it's done you just stop thinking about credential rotation entirely; that alone is worth the effort.

Start with one non-critical repository, migrate it fully, document what you learned, and then scale from there. The audit layers (GitHub logs, Entra logs, Azure Activity logs, Key Vault logs) might feel like a lot, but they're what let you answer "who did what and when" without scrambling; and trust me, your auditors will thank you.

That being said, have a good one!