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:
- GitHub Action triggers: Whether it’s a pull request or a merge, your workflow kicks off
- OIDC token generated: GitHub hands out a temporary identity token, which Azure trusts (thanks to a bit of setup magic)
- Terraform runs: With authentication sorted, your code plans and applies changes in Azure-no secrets required
- 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:
- Creates an Azure AD application and service principal if they don’t already exist
- Configures three federated credentials for GitHub Actions workflows:
- For the main branch
- For a renovate/configure branch
- For pull requests
- All credentials are associated with a specific GitHub repository (thomast1906/terraform-github-oidc)
- 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
settingson repository - Select
secrets and variables-> Actions - Update repository secrets with the 3 values above

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:
- Finds the Azure AD application for
terraform-github-oidc-example-OIDC - Validates the service principal exists
- Gets the current Azure subscription details
- Checks if a Contributor role assignment already exists at subscription level
- 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:
- Checks out your code
- Sets up Terraform
- Initialises and formats your Terraform code
- 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: write– Critical 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 providerARM_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_OIDCis 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