Terraform Pipelines for Dummies Part3: GitHub Actions Azure Deploy with OIDC

Intro

Did you know that over 23 millions secrets were publicly exposed in GitHub in 2024 alone? and even 70% of the secrets leaked in 2022 are still valid? This is additional evidence that leaked secrets are still the number one biggest threat to your business. The worst thing to do is make it easy for hackers to infiltrate your company’s system.

Image Not Found


That’s where workload identity federation—like OIDC—steps in to make cloud authentication seamless, secure, and secret-free. In this post we’ll help you setup an Azure managed Identity (New) to authenticate your GitHub workflows using OIDC to deploy in Azure cloud.

What is OIDC ?

OpenID Connect (OIDC) is an authentication protocol built on top of OAuth 2.0 which adds an ID layer (ID token) to enable user authentication and Single Sign-On on top of OAUTH’s access delegation. If you want to learn more about both OAuth and OIDC check out our post: What is OIDC?

You can also check out my deep dive around OIDC authentication in the cloud.

Key Benefits

  • No static secrets stored in GitHub repositories.
  • Automatic credential rotation with every workflow run.
  • Simplified role assumption via OIDC.

How Does It Work?

We use OpenID Connect (OIDC) to create a trust relationship between your GitHub pipeline and your Azure(Entra). Meaning when you push code to your branch, GitHub Actions will authenticate itself to Azure without requiring static credentials (service principal secrets).


OIDC Workflow

There are 2 pillars to the OIDC authentication (see details here):

  1. First, App registration in Azure to set up the OIDC trust and allow pipeline authentication via ID token(JWT).
  2. Then you can update your GitHub actions workflow with azure action info, to authenticate using tokens.
Image Not Found
JWT (Jason Web Token): is a core component of OIDC containing information about the signed identity.

Getting started

What Are We Doing ?

We’re setting up a Terraform pipeline to deploy an Azure RG & VNET in AZURE without any service principal secret. Instead, we’ll use GitHub Actions workload identity to assume an Azure role directly via OIDC.
There are two ways to do it:

  1. Option 1: Microsoft Entra application (service principal)
  2. Option 2: User-assigned managed identity
💡Bonus: Today, we will use the managed identity method as it doesn’t require the app registration step (simpler).

Access Token lifetime

  • Azure User managed identity method: 24hs
  • Azure Service principal(entra app) method: 1hr

I. My GitHub Terraform Pipeline

To demonstrate how to configure OIDC in Azure, I’ve set up a GitHub repository with a Terraform pipeline (see below):

Image Not Found
>> git_actions

Workload Directory: Terraform configuration to deploy a VNET is located in the terraform-provider-azure/create-vnet directory of the repo.

Image Not Found
Image Not Found
  • Environment variables and secrets will be required for our git pipeline.

Repository: github.com/brokedba/terraform-examples
Branch: github_actions
Environment: az-labs

II. Configuring OIDC in Azure

Before using Azure Login Action with OIDC, you need to configure a federated identity credential on Azure Entra.

Step 1: Setup a managed identity (OIDC Trust)

Create user-assigned managed identity (i.e oidc-managed-gitaction).

  1. On the Azure portal Search box, enter Managed Identities, then click +create button.
  2. Specify following fields: Subscription | Resource Group | Name: oidc-managed-gitaction | Region
  3. Copy Client IDSubscription ID, and TENANT_ID for later use.

Step 2: Create an IAM Role and assign it to the managed Identity

1. Use built-in roles (Contributor, VM Contributor ) or create a custom role for least privilege. See tutorial for my CLI-based custom role (vm-vnet-rg-role) creation using a json role definition.

# Create a role "vm-vnet-rg-role" with permissions on [Resource-Groups/VMs/Vnets] 
az role definition create --role-definition @vm-vnet-role-all-rgs-scoped.json


2. Assign the role to the managed identity (Subscription level):

  • Go to managed identity>oidc-managed-gitaction>Azure role assignment
    • Select the scope [subscription] and add the role from the list
Azure CLI command
# Run by a privileged user/SPN
TF_MI_OBJECT_ID="{your-terraform-managed-identity-object-id}"
SUB_ID="{yourSubscriptionId}"
ROLE_NAME="vm-vnet-role" # Or whatever you named the updated role

az role assignment create --assignee-object-id $TF_MI_OBJECT_ID \
                          --role "$ROLE_NAME" \
                          --scope "/subscriptions/$SUB_ID"
Note: You can also find a Resource Group scoped role here.
My terraform config creates a Resource Group, hence the need for a higher scope. But you can lower the scope.

Step 3: Add a federated identity credential to the managed identity

  • Go to managed identity>oidc-managed-gitaction>Settings>federated credentials and click add credentials
  • Select [GitHub Action deploying Azure resource] Scenario
    1. In the GitHub claim, select the right Organization | Repo | Entity type : Value
    2. The entity can be either: { branch | environment | pull-request | tag}
    3. We will choose environment with value "az-labs" (see my repo )
Note: The final claim combination should look like [repo:yourRepo/terraform-examples:environment:az-labs] .

Step 4: Configure GitHub Actions Workflow

Once OIDC trust is set, create a workflow where the azure-login action will receive a JWT from the GitHub ID provider, then requests an access token from Azure.

🚀 Action : Owner avatar  Azure/login   

Image Not Found
The 3 input parameters aren’t real secrets but we defined them as secrets in the az-labs environment
Image Not Found
  • Create a workflow triggered by push on the “git_actions” branch from tf files under create-vnet
  • Prerequisites: Set id-token permission = write to allow this action to generate the JWT Token.
name: 'Terraform_azure_vnet'

on:
  push:
    branches: [ "git_actions" ]
    paths:
      - 'terraform-provider-azure/create-vnet/*tf'  # tf files changes       
env:
  TF_VAR_az_location: "${{ vars.az_location }}"
  TF_VAR_prefix: "${{ vars.TF_APP_PREFIX }}" 
  TF_VAR_az_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} # azurerm v4.0
  STACK_DIR: ${{ vars.TF_STACK_DIR }}

permissions:
  id-token: write     # Can also be put under the azure login job
  • Add the azure/login action info to authenticate to Azure from the pipeline.
    #  Authenticate with Azure using OIDC Workload Federated Identiry  
    - name: 'Az CLI login'
      uses: azure/login@v1
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

link: Terraform_azure_vnet

name: 'Terraform_azure_vnet'

on:
  push:
    branches: [ "git_actions" ]
    paths:
      - 'terraform-provider-azure/create-vnet/*tf'         
env:
  #ARM_USE_MSI: true
  #ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}  
  #ARM_TENANT_ID: "${{ secrets.AZURE_TENANT_ID }}"
  #ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
  TF_VAR_az_location: "${{ vars.az_location }}"
  TF_VAR_prefix: "${{ vars.TF_APP_PREFIX }}" 
  TF_VAR_az_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
  STACK_DIR: ${{ vars.TF_STACK_DIR }}
  
permissions:
  id-token: write

jobs:
 # ############
 # INIT
 # ############
  terraform_setup:
    name: 'Terraform Init-Validate'
    runs-on: ubuntu-latest
    environment: az-labs
  # Use default shell and working directory regardless of the os of the GitHub Actions runner 
    defaults:
     run:
       shell: bash
       working-directory: ${{ env.STACK_DIR }}

    steps:
    # Checkout the repository to the GitHub Actions runner
    - name: Checkout
      uses: actions/checkout@v3

    # Install the latest version of Terraform CLI and configure the Terraform CLI configuration file with a Terraform Cloud user API token
    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v1
      with:
        terraform_version: 1.0.3
        terraform_wrapper: false
           # cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }} --> Terraform cloud
    
    # Create a cache for the terraform pluggin and copy tf binary
    - name: Config Terraform plugin cache
      run: |
        echo 'plugin_cache_dir="$HOME/.terraform.d/plugin-cache"' >~/.terraformrc
        mkdir --parents ~/.terraform.d/plugin-cache
        terra_bin=`which terraform`
        cp $terra_bin .
     
    # Initialize a new or existing Terraform working directory(creating initial files, loading any remote state, downloading modules..)
    - name: Terraform Init
      id: init
      run: |
        echo ====== INITIALIZE terraform provider plugins : $GITHUB_WORKSPACE/${{ vars.TF_STACK_DIR }} ======
        pwd
        echo  terra_bin=$terra_bin >> "$GITHUB_OUTPUT"
        # echo the temp directory path is : $RUNNER_TEMP
        terraform init
        terraform -v  
    - name: Terraform format
      run: |
        echo  ====== FORMAT the Terraform configuration in ${{ env.STACK_DIR }}  ======
        terraform fmt  
    - name: Terraform Validate
      run: |
        echo  ====== VALIDATE the Terraform configuration in ${{ env.STACK_DIR }}  ======
        terraform validate  
    #  Authenticate with Azure using OIDC Workload Federated Identiry (i.e User Manged Identity)       
    - name: 'Az CLI login'
      uses: azure/login@v1
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
# PLAN
    - name: Terraform Plan
      id: plan
      run: |
        echo  ====== PLAN execution for the Terraform configuration in ${{ env.STACK_DIR }}  ======
        terraform plan -input=false -no-color -out tf.plan

    #  Save all plugin files and working Directory in a cache
    - name: Cache Terraform
      uses: actions/cache@v3
      with:
        path: |
          ~/.terraform.d/plugin-cache
          ./*
        key: ${{ runner.os }}-terraform-${{ env.STACK_DIR }} 
        restore-keys: |
          ${{ runner.os }}-terraform-${{ env.STACK_DIR }}        
    #         ${{ hashFiles('**/.terraform.lock.hcl') }}             
    outputs:
      terra_path: ${{ steps.init.outputs.terra_bin }}      
 # ############
 # APPLY
 # ############ 
  Terraform_Apply:
    name: 'Terraform Apply'
    runs-on: ubuntu-latest
    environment: az-labs
    needs: [terraform_setup]
# Use default shell and working directory regardless of the os of the GitHub Actions runner 
    defaults:
      run:
        shell: bash
        working-directory: ${{ env.STACK_DIR }}
    steps:
    # Checkout the repository to the GitHub Actions runner not necessary. The cache has it
    - name: Cache Terraform
      uses: actions/cache@v3
      with:
        path: |
          ~/.terraform.d/plugin-cache
          ./*
        key: ${{ runner.os }}-terraform-${{ env.STACK_DIR }}
        restore-keys: |
          ${{ runner.os }}-terraform-${{ env.STACK_DIR }}     
    # Configure terraform pluggin in the new runner                          
    - name: Config Terraform plugin cache
      run: |
        echo 'plugin_cache_dir="$HOME/.terraform.d/plugin-cache"' >~/.terraformrc

    # terraform init not needed here . int files are already in the cache
        # TERRAPATH="${{ needs.terraform_setup.outputs.terra_path }}"
        # echo old terraform binary location: $TERRAPATH
    #  Authenticate with Azure using OIDC Workload Federated Identiry (i.e User Manged Identity)      
    - name: 'Az CLI login'
      uses: azure/login@v1
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# APPLY    
    - name: Terraform Apply
      id: plan
      if: github.event_name == 'push'
      run: |
        echo  ====== APPLY execution for the Terraform configuration in ${{ env.STACK_DIR }}  ======
        echo "== Reusing cached version of terraform =="
        sudo cp ./terraform  /usr/local/bin/
        terraform -v      
        terraform plan -input=false -no-color -out tf.plan
        terraform apply --auto-approve tf.plan
    # Create a cache for the terraform state file 
    - name: Cache Terraform statefile
      uses: actions/cache@v3
      with:
        path: |
          ${{ env.STACK_DIR }}/terraform.tfstate
        key: ${{ runner.os }}-terraform-apply-${{ github.run_id }}
        restore-keys: |
          ${{ runner.os }}-terraform-apply-${{ github.run_id }}     
 # ############
 # DESTROY
 # ############
  Terraform_Destroy:
    name: 'Terraform Destroy'
    runs-on: ubuntu-latest
    environment: az-labs
    permissions: write-all
    needs: [Terraform_Apply]
# Use default shell and working directory regardless of the os of the GitHub Actions runner 
    defaults:
      run:
        shell: bash
        working-directory: ${{ env.STACK_DIR }}
    steps:
    # Checkout the repository to the GitHub Actions runner not necessary. The cache has it
    - name: Cache Terraform
      uses: actions/cache@v3
      with:
        path: |
          ~/.terraform.d/plugin-cache
          ./*
        key: ${{ runner.os }}-terraform-${{ env.STACK_DIR }}
        restore-keys: |
          ${{ runner.os }}-terraform-${{ env.STACK_DIR }}     
    # Configure terraform pluggin in the new runner                          
    - name: Config Terraform plugin cache
      run: |
        echo 'plugin_cache_dir="$HOME/.terraform.d/plugin-cache"' >~/.terraformrc
    # Restore a cache for the terraform state file 
    - name: Cache Terraform statefile
      uses: actions/cache@v3
      with:
       path: |
         ${{ env.STACK_DIR }}/terraform.tfstate
       key: ${{ runner.os }}-terraform-apply-${{ github.run_id }}
       restore-keys: |
         ${{ runner.os }}-terraform-apply             
    # terraform init not needed here . int files are already in the cache
    #        ls terraform.tfstate
    #  Authenticate with Azure using OIDC Workload Federated Identiry (i.e User Manged Identity)  
    - name: 'Az CLI login'
      uses: azure/login@v1
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}    
    # clean terraform cache after destroy completion  
# DESTROY      
    - name: Terraform Destroy
      id: destroy
      run: |
        echo  ====== Destroy the Terraform configuration in ${{ env.STACK_DIR }}  ======
        echo "== Reusing cached version of terraform =="
        sudo cp ./terraform  /usr/local/bin/
        terraform -v      
        terraform destroy --auto-approve
    # clean terraform cache after destroy completion       
    - name: clean cache
      id: cache_deletion
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        gh extension install actions/gh-actions-cache
        echo " deleting tfstate caches"
        gh actions-cache list
        gh actions-cache delete ${{ runner.os }}-terraform-apply-${{ github.run_id }} --confirm
        gh actions-cache delete ${{ runner.os }}-terraform-${{ env.STACK_DIR }}   --confirm

How to check your git actions claims?

In case of errors, the actions-oidc-debugger action is the perfect tool to validate your ID token (JWT) claims.

Here’s an excerpt of my repo claims:
Example: OIDC Token claims for “brokedba”s github actions workflow

{   "actor": "brokedba",
    "aud": "https://github.com/brokedba",
    "environment": "gcp-labs",
    "event_name": "push",
    "iss": "https://token.actions.githubusercontent.com",
    "job_workflow_ref": "brokedba/terraform-examples/.github/workflows/..",
    "ref": "refs/heads/git_actions",
    "ref_type": "branch",
    "repository": "brokedba/terraform-examples",
    "repository_owner": "brokedba",
snip ...
    "sub": "repo:brokedba/terraform-examples:environment:gcp-labs",  <<---
    "workflow": "Terraform_gcp_vpc",
snip ...
  }
Note: Standard claims include audience, issuer, and subject. Full description can be found here > Github Doc

Step5 (Final) : Test the Pipeline

Once the configuration is complete, the last step is to test the pipeline to ensure everything works as expected. Our workflow has 3 different Jobs :

1. Terraform init-validate
2. Terraform Apply
3. Terraform Destroy

Image Not Found

Push changes to the github_actions branch to trigger the GitHub pipeline.

Image Not Found

Go to Actions tab in the GitHub repo & select the latest run. Check the log.

Image Not Found

check the portal quickly as terraform apply job is followed by a destroy

JWT (Jason Web Token): is a core component of OIDC containing information about the signed identity.

III. How to Fork My Repo to Try OIDC

  1. Visit the Repository: Go to https://github.com/brokedba/terraform-examples.
  2. Fork the Repository:
  3. Create an environment called az_labs linkedin to git_actions branch
  4. Follow the Azure OIDC configuration steps in this article
  5. Add scoped secrets and variable accordingly under the az_labs environment
    • secret: AZURE_CLIENT_ID (from your managed identity)
    • secret: AZURE_SUBSCRIPTION_ID (from your managed identity)
    • secret: AZURE_TENANT_ID (from your Entra overview page)
    • Variables
      • TF_STACK_DIR: terraform-provider-aws/create-vpc
      • AZ_LOCATION: i.e eastus
      • TF_AP_PREFIX: Prefix for the application used to name the resource Group.
  6. Switch to the git_actions Branch
  7. Commit any tf file modification under terraform-provider-azure/create-vnet folder

Conclusion

Integrating GitHub Actions with Azure managed identity using OIDC provides a seamless, secure, and keyless authentication workflow. By leveraging OIDC trust, you eliminate the need for long-term credentials, reducing secret management overhead (storage, duplication, rotation) and simplifying cloud access.
The below best practices should already be in your baseline.

Best Practices

  • Use short-lived credentials with OIDC to improve security.
  • Scope trust policies to specific branches and repositories.
  • Test the IAM role with minimal permissions (least privilege).

Don’t let your workflow be part of the 2025 secret sprawl report ;).

🙋🏻‍♀️If you like this content please subscribe to our blog newsletter ❤️.

👋🏻Want to chat about your challenges?
We’d love to hear from you! 

Share this…

Don't miss a Bit!

Join countless others!
Sign up and get awesome cloud content straight to your inbox. 🚀

Start your Cloud journey with us today .