Deploy Terraform to Azure with OIDC and GitHub Actions

Recently I blogged about Deploying to Azure: Secure Your GitHub Workflow with OIDC, this is a follow on post – looking at deploying Terraform to Azure using OIDC and GitHub Actions

With Microsoft and GitHub both emphasising identity-based access, using OIDC for Terraform deployments isn’t just secure. It will become the new norm. OIDC authentication eliminates the need for storing long-lived cloud secrets in your GitHub repositories. Instead, it uses short-lived tokens, enhancing security and simplifying secret management (No need to remember any secrets or keep them stored securely – awesome!)

Why Terraform & OIDC?

When your pipeline depends on static secrets buried in GitHub, that dream gets messy. Secrets expire! They get rotated (hopefully). Sometimes they get left out altogether – we’ve all seen that PR with someone’s credentials checked in by accident.

OIDC solves that by replacing those long-lived secrets with short-lived identity tokens. It’s like swapping a rusty old key for a temporary passcode that changes every time. No storage, no leakage, no problem! 🙂

Here’s a high-level structure of how it all comes together:

  1. GitHub Action triggers: Whether it’s a pull request or a merge, your workflow kicks off
  2. OIDC token generated: GitHub hands out a temporary identity token, which Azure trusts (thanks to a bit of setup magic)
  3. Terraform runs: With authentication sorted, your code plans and applies changes in Azure-no secrets required
  4. Resources deployed: Your infrastructure is deployed in Azure, and you haven’t stored a single credential in your repo

Step-by-Step: Deploying Terraform with OIDC in GitHub Actions

Setting up Azure AD Application ready for OIDC

I have created a script to setup GitHub OIDC authentication with Azure, that will:

  1. Creates an Azure AD application and service principal if they don’t already exist
  2. Configures three federated credentials for GitHub Actions workflows:
    • For the main branch
    • For a renovate/configure branch
    • For pull requests
  3. All credentials are associated with a specific GitHub repository (thomast1906/terraform-github-oidc)
  4. Outputs the necessary IDs and values that need to be added as GitHub repository secrets

Prior to running the below, can you update APP_DISPLAY_NAME & GITHUB_REPO to your relevant values

Before running the below script, you need to update the APP_DISPLAY_NAME and GITHUB_REPO variables. Make sure they match your project. Then, follow the prompts. The script even reminds you to add three critical secrets to your GitHub repo: AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID.

#!/bin/sh

# Configuration
APP_DISPLAY_NAME="terraform-github-oidc-example-OIDC"
GITHUB_REPO="thomast1906/terraform-github-oidc"

# Error handling function
handle_error() {
    echo "ERROR: $1"
    exit 1
}

# Verify Azure CLI is installed and user is logged in
if ! command -v az &> /dev/null; then
    handle_error "Azure CLI is not installed. Please install it first: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
fi

# Check if user is logged in
echo "Verifying Azure CLI login status..."
az account show &> /dev/null || handle_error "You are not logged in to Azure CLI. Please run 'az login' first."

# Check if Azure AD App already exists
echo "Checking if Azure AD application $APP_DISPLAY_NAME already exists..."
APP_EXISTS=$(az ad app list --display-name "$APP_DISPLAY_NAME" --query "[].displayName" -o tsv)

if [ "$APP_EXISTS" = "$APP_DISPLAY_NAME" ]; then
    echo "Azure AD application $APP_DISPLAY_NAME already exists."
    APP_ID=$(az ad app list --display-name "$APP_DISPLAY_NAME" --query "[0].appId" -o tsv)
else
    # Create Azure AD application registration
    echo "Creating Azure AD application $APP_DISPLAY_NAME..."
    APP_ID=$(az ad app create --display-name "$APP_DISPLAY_NAME" --query appId -o tsv) || handle_error "Failed to create Azure AD application"
fi

# Check if service principal exists
echo "Checking if service principal for $APP_DISPLAY_NAME already exists..."
SP_EXISTS=$(az ad sp list --filter "appId eq '$APP_ID'" --query "[].id" -o tsv)

if [ -n "$SP_EXISTS" ]; then
    echo "Service principal for $APP_DISPLAY_NAME already exists."
    SP_ID=$SP_EXISTS
else
    # Create service principal
    echo "Creating service principal for $APP_DISPLAY_NAME..."
    SP_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv) || handle_error "Failed to create service principal"
fi

# Function to create or update federated credential
create_federated_credential() {
    local name=$1
    local subject=$2
    local description=$3
    
    echo "Checking if federated credential $name already exists..."
    CRED_EXISTS=$(az ad app federated-credential list --id "$APP_ID" --query "[?name=='$name'].name" -o tsv)
    
    if [ "$CRED_EXISTS" = "$name" ]; then
        echo "Federated credential $name already exists."
    else
        echo "Creating federated credential $name..."
        az ad app federated-credential create \
          --id "$APP_ID" \
          --parameters "{
            \"name\": \"$name\",
            \"issuer\": \"https://token.actions.githubusercontent.com\",
            \"subject\": \"$subject\",
            \"description\": \"$description\",
            \"audiences\": [\"api://AzureADTokenExchange\"]
          }" || handle_error "Failed to create federated credential $name"
    fi
}

# Create federated credentials for different GitHub workflows
create_federated_credential "github-oidc-branch" "repo:$GITHUB_REPO:ref:refs/heads/main" "GitHub Actions OIDC - Branch Workflows (main)"
create_federated_credential "github-oidc-branch-renovate" "repo:$GITHUB_REPO:ref:refs/heads/renovate/configure" "GitHub Actions OIDC - Branch Renovate Workflows (renovate)"
create_federated_credential "github-oidc-pull-request" "repo:$GITHUB_REPO:pull_request" "GitHub Actions OIDC - Pull Request Workflows"

# Get subscription ID
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

echo "✅ Setup complete!"
echo "==========================================================================="
echo "  APPLICATION (CLIENT) ID: $APP_ID"
echo "  SERVICE PRINCIPAL ID:    $SP_ID"
echo "  TENANT ID:               $(az account show --query tenantId -o tsv)"
echo "  SUBSCRIPTION ID:         $SUBSCRIPTION_ID"
echo "==========================================================================="
echo "For GitHub Actions, add these secrets to your repository:"
echo "  AZURE_CLIENT_ID:         $APP_ID"
echo "  AZURE_TENANT_ID:         $(az account show --query tenantId -o tsv)"
echo "  AZURE_SUBSCRIPTION_ID:   $SUBSCRIPTION_ID"
echo "==========================================================================="

As part of the script output, it will provide 3 secrets you need to add to your GitHub repository:

For GitHub Actions, add these secrets to your repository:
  AZURE_CLIENT_ID:         c66a8113-081c-47c8-b777-b5463ae51bc7
  AZURE_TENANT_ID:         8d1b0a04-ae70-4b0a-b26a-8ad9fdd7807e
  AZURE_SUBSCRIPTION_ID:   04109105-f3ca-44ac-a3a7-66b4936112c3


Add these secrets to your repository:

  • Select settings on repository
  • Select secrets and variables -> Actions
  • Update repository secrets with the 3 values above
GitHub screenshot showing repository secrets highlighted that need to be added

Setting Application permissions to the relevant subscription

You may want to change this, but I have provided the newly created Azure AD application with contributor permissions to my subscription

Prior to running the below, can you update APP_DISPLAY_NAME to the application you created above, this script will:

  1. Finds the Azure AD application for terraform-github-oidc-example-OIDC
  2. Validates the service principal exists
  3. Gets the current Azure subscription details
  4. Checks if a Contributor role assignment already exists at subscription level
  5. If not, creates the role assignment (granting broad Contributor permissions)
#!/bin/sh

# Configuration
APP_DISPLAY_NAME="terraform-github-oidc-example-OIDC"

# Error handling function
handle_error() {
    echo "ERROR: $1"
    exit 1
}

# Verify Azure CLI is installed and user is logged in
if ! command -v az &> /dev/null; then
    handle_error "Azure CLI is not installed. Please install it first: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
fi

# Check if user is logged in
echo "Verifying Azure CLI login status..."
az account show &> /dev/null || handle_error "You are not logged in to Azure CLI. Please run 'az login' first."

# Get application ID and verify it exists
echo "Retrieving application ID for $APP_DISPLAY_NAME..."
APP_ID=$(az ad app list --display-name "$APP_DISPLAY_NAME" --query "[0].appId" -o tsv)
if [ -z "$APP_ID" ]; then
    handle_error "Application $APP_DISPLAY_NAME not found. Please verify the app name."
fi
echo "Found application ID: $APP_ID"

# Get service principal ID
echo "Retrieving service principal ID for application..."
SP_ID=$(az ad sp list --filter "appId eq '$APP_ID'" --query "[0].id" -o tsv)
if [ -z "$SP_ID" ]; then
    handle_error "Service principal for application $APP_DISPLAY_NAME not found."
fi
echo "Found service principal ID: $SP_ID"

# Get subscription ID
echo "Getting subscription ID..."
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
if [ -z "$SUBSCRIPTION_ID" ]; then
    handle_error "Failed to retrieve subscription ID."
fi
echo "Found subscription ID: $SUBSCRIPTION_ID"

# Set subscription scope
SUBSCRIPTION_SCOPE="/subscriptions/$SUBSCRIPTION_ID"

# Check if role assignment already exists to avoid duplicates
echo "Checking if subscription-level role assignment already exists..."
EXISTING_ROLE=$(az role assignment list --assignee "$APP_ID" --scope "$SUBSCRIPTION_SCOPE" --role "Contributor" --query "[].id" -o tsv)
if [ -n "$EXISTING_ROLE" ]; then
    echo "Contributor role assignment already exists for this application at the subscription level."
else
    # Assign permissions to the application at subscription level
    echo "Assigning 'Contributor' role to the application at subscription level..."
    az role assignment create --assignee "$APP_ID" --role "Contributor" --scope "$SUBSCRIPTION_SCOPE" || \
        handle_error "Failed to assign Contributor role to the application at subscription level."
    echo "Successfully assigned role to application."
fi

# Summary with more detailed subscription information
SUBSCRIPTION_NAME=$(az account show --query name -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)

echo "✅ Operation completed"
echo "==========================================================================="
echo "  APPLICATION NAME:        $APP_DISPLAY_NAME"
echo "  APPLICATION (CLIENT) ID: $APP_ID"
echo "  SERVICE PRINCIPAL ID:    $SP_ID"
echo "  TENANT ID:               $TENANT_ID"
echo "  SUBSCRIPTION ID:         $SUBSCRIPTION_ID"
echo "  SUBSCRIPTION NAME:       $SUBSCRIPTION_NAME"
echo "==========================================================================="
echo "The application has been assigned 'Contributor' role at the subscription level."
echo "This gives it access to manage ALL resources in the subscription."
echo "==========================================================================="
echo "SECURITY NOTE: Assigning Contributor at the subscription level grants broad"
echo "permissions. Consider restricting to specific resource groups if possible."
echo "==========================================================================="

GitHub Action Terraform Workflow

The below GitHub Actions workflow will do the following:

  1. Checks out your code
  2. Sets up Terraform
  3. Initialises and formats your Terraform code
  4. Plans and applies changes but only applies on merges to the main branch, keeping your infrastructure safe from accidental changes on pull requests
name: Terrform-Deploy

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  workflow_dispatch:

jobs:
  terraform:
    name: Terrform-Deploy
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write  # Required for OIDC
      
    env:
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_USE_OIDC: true
      tf_resource_group_name: "thomasthorntoncloud"
      tf_storage_account_name: "thomasthorntontfstate"
      tf_state_container: "github-oidc-terraform-example-tfstate"
      tf_state_key: "terraform.tfstate"


    steps:
    - name: Checkout Code
      uses: actions/checkout@v4

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v3
      with:
        terraform_version: 1.11.0
        terraform_wrapper: true


    - name: Terraform Init
      run: terraform init
      working-directory: .

    - name: Terraform Format
      if: github.event_name == 'pull_request'
      run: terraform fmt
      working-directory: .

    - name: Auto Commit Changes
      uses: stefanzweifel/git-auto-commit-action@v5
      if: github.event_name == 'pull_request'
      with:
        commit_message: "Terraform fmt"
        file_pattern: "*.tf *.tfvars"
        commit_user_name: "github-actions[bot]"

    - name: Terraform Plan
      run: terraform plan -no-color -input=false
      working-directory: .

    - name: Terraform Apply
      if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
      run: terraform apply -auto-approve -input=false
      working-directory: .

How It Uses OIDC

This workflow uses Azure OIDC authentication with several key components:

1. Permission Setup: The job requests one critical permission:

  • id-token: writeCritical for OIDC – Enables requesting the OIDC token from GitHub

2. Azure Authentication Environment Variables:

  • ARM_USE_OIDC: true – Explicitly enables OIDC authentication for the Azure provider
  • ARM_CLIENT_ID – The Azure app registration client ID (from secrets)
  • ARM_TENANT_ID – The Azure tenant ID (from secrets)
  • ARM_SUBSCRIPTION_ID – The Azure subscription ID (from secrets)

3. Token Exchange Process:

  • When ARM_USE_OIDC is true, Terraform’s Azure provider automatically:
    • Requests a JWT token from GitHub’s OIDC provider
    • Exchanges this token with Azure AD for an access token
    • Uses this token to authenticate with Azure

Wrapping up

It’s fast. It’s secure. And once you’ve done it once, it’s dead easy to replicate across projects

  • Gone are the days of praying that a secret hasn’t expired mid-pipeline
  • Gone are the frantic last-minute client secret rotations

Instead? OIDC just works. And when paired with Terraform and GitHub Actions, it unlocks a CI/CD flow that feels right

GitHub repository containing examples from above

Leave a Reply

Discover more from Thomas Thornton Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading

Discover more from Thomas Thornton Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading