Introduction#
The old way of authenticating to Azure from your GitHub Actions workflows involved storing a static client secret in your GitHub secrets store. There is a better way of doing it, which is what I will demonstrate in this post.
Federated identity credentials#
Traditionally you would use secrets or certificates to authenticate to Azure from external systems, such as GitHub Actions. Federated identity credentials is a new type of credential where you do not need to manage any credentials at all - somewhat unintuitive. The whole process is based on establishing a trust relationship between the external system and an app in Azure.
Once this trust is established the process of obtaining a token goes like this:
Putting words to the labels in the image, but using GitHub, GitHub Actions, Entra ID, and Azure instead of the generic terms in the image:
- GitHub Actions workflow requests a token from GitHub
- GitHub issues a token to the workflow
- The workflow sends the token to Entra ID (Azure Active Directory in the image)
- Entra ID validates the token with GitHub
- Entra ID issues an access token to the workflow
- The workflow can use the access token to perform actions in Azure
Automate the setup with Terraform#
Since I will be configuring resources in multiple systems (Azure, Entra ID, and GitHub) it makes sense to use Terraform for this provisioning. Of course I could also use other tools for this, e.g. Pulumi, but since I belong to Team Declarative I prefer Terraform.
Providers#
To set everything up I will use three providers: azurerm
, azuread
, and github
. I use the current latest versions1:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "3.106.1"
}
azuread = {
source = "hashicorp/azuread"
version = "2.50.0"
}
github = {
source = "integrations/github"
version = "6.2.1"
}
}
}
I have credentials for Azure and GitHub configured in my terminal where I will run Terraform, but I still need to configure the azurerm
and github
providers:
provider "azurerm" {
subscription_id = var.azure_subscription_id
features {}
}
provider "github" {
owner = var.github_organization_name
}
Here I have parametrized the providers to allow working with any Azure subscription and any GitHub organization or user. This is overkill in my case because I only have a single Azure subscription and only my private GitHub account.
Variables#
I have already referenced two variables from my provider configurations:
variable "github_organization_name" {
description = "GitHub organization (or user)"
type = string
}
variable "azure_subscription_id" {
description = "What Azure subscription should the workload identity have access to?"
type = string
}
I will also need two other variables related to GitHub and Azure:
variable "github_repository_name" {
description = "The GitHub repository to set up workload identity for"
type = string
}
variable "azure_roles" {
description = "Which roles to assign to the workload identity in Azure?"
type = list(string)
}
The github_repository_name
variable is self-explanatory. The azure_roles
variable is a list of Azure role names that should be assigned to the identity that will be created. If I plan on creating and managing a lot of Azure infrastructure through my GitHub Actions workflows I will probably need to pass in Contributor
or even Owner
in this list. Note that the list contains the friendly names of the roles, no need to enter GUIDs or full resource IDs.
Finally I want to be able to set up a number of federated identity credentials to allow multiple branches, tags, and GitHub environments to be able to authenticate to Azure. For this purpose I add the following variables:
variable "branches" {
description = "List of git branches to add as subject identifiers"
type = list(string)
default = []
}
variable "tags" {
description = "List of git tags to add as subject identifiers"
type = list(string)
default = []
}
variable "environments" {
description = "List of GitHub environments to add as subject identifiers"
type = list(string)
default = []
}
variable "pull_request" {
description = "Add the 'pull request' subject identifier?"
type = bool
default = false
}
The last variable, pull_request
, is a simple bool
to indicate if workflows running in the pull request phase should be able to authenticate to Azure as well.
Azure app registration and service principal#
To start with I define the Azure app registration resource:
resource "azuread_application" "this" {
display_name = "github-${var.github_repository_name}"
}
There are additional things you could configure for this resource, but this is the bare minimum we can get away with. Next I create the corresponding service principal:
resource "azuread_service_principal" "this" {
client_id = azuread_application.this.client_id
}
Azure role assignments#
I want my workflows to be able to do things in my Azure subscription. This is why I added the list of role names as a variable. The role assignments use this variable:
data "azurerm_subscription" "current" {
subscription_id = var.azure_subscription_id
}
resource "azurerm_role_assignment" "this" {
for_each = toset(var.azure_roles)
scope = data.azurerm_subscription.current.id
principal_id = azuread_service_principal.this.object_id
principal_type = "ServicePrincipal"
role_definition_name = each.value
skip_service_principal_aad_check = true
}
I use a for_each
to simplify creating role assignments for each role in the list.
GitHub secrets#
We need three values from our Azure setup available in our GitHub Actions workflows. Notably we do not need a client secret, that was the whole point! However, we do need these:
data "github_repository" "this" {
name = var.github_repository_name
}
resource "github_actions_secret" "azure_client_id" {
repository = data.github_repository.this.name
secret_name = "AZURE_CLIENT_ID"
plaintext_value = azuread_application.this.client_id
}
resource "github_actions_secret" "azure_tenant_id" {
repository = data.github_repository.this.name
secret_name = "AZURE_TENANT_ID"
plaintext_value = data.azurerm_subscription.current.tenant_id
}
resource "github_actions_secret" "azure_subscription_id" {
repository = data.github_repository.this.name
secret_name = "AZURE_SUBSCRIPTION_ID"
plaintext_value = data.azurerm_subscription.current.subscription_id
}
Specifically I have added AZURE_CLIENT_ID
, AZURE_TENANT_ID
, and AZURE_SUBSCRIPTION_ID
. I could have added them as environment variables instead of secrets.
Federated identity credentials#
The only thing left are the federated identity credentials. The configuration varies slightly depending on what entity (branch, tag, environment, pull request) that we are configuring. For branches, tags and environments I use for_each
constructs:
resource "azuread_application_federated_identity_credential" "branches" {
for_each = toset(var.branches)
application_id = "/applications/${azuread_application.this.object_id}"
display_name = "github-${var.github_organization_name}.${var.github_repository_name}-${each.value}"
description = "GitHub federated identity credentials"
subject = "repo:${var.github_organization_name}/${var.github_repository_name}:ref:refs/heads/${each.value}"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
}
resource "azuread_application_federated_identity_credential" "tags" {
for_each = toset(var.tags)
application_id = "/applications/${azuread_application.this.object_id}"
display_name = "github-${var.github_organization_name}.${var.github_repository_name}-${each.value}"
description = "GitHub federated identity credentials"
subject = "repo:${var.github_organization_name}/${var.github_repository_name}:ref:refs/tags/${each.value}"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
}
resource "azuread_application_federated_identity_credential" "environments" {
for_each = toset(var.environments)
application_id = "/applications/${azuread_application.this.object_id}"
display_name = "github-${var.github_organization_name}.${var.github_repository_name}-${each.value}"
description = "GitHub federated identity credentials"
subject = "repo:${var.github_organization_name}/${var.github_repository_name}:environment:${each.value}"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
}
For the pull request entity I use a count
:
resource "azuread_application_federated_identity_credential" "pull_request" {
count = var.pull_request ? 1 : 0
application_id = "/applications/${azuread_application.this.object_id}"
display_name = "github-${var.github_organization_name}.${var.github_repository_name}-pr"
description = "GitHub federated identity credentials"
subject = "repo:${var.github_organization_name}/${var.github_repository_name}:pull_request"
audiences = ["api://AzureADTokenExchange"]
issuer = "https://token.actions.githubusercontent.com"
}
Provision resources#
To test this out I create a main.auto.tfvars
file with values for my variables:
azure_roles = ["Reader"]
azure_subscription_id = "<my azure subscription id>"
branches = ["main", "feature-1", "feature-2"]
environments = ["test", "uat", "prod"]
tags = ["latest"]
pull_request = true
github_organization_name = "mattias-fjellstrom"
github_repository_name = "demo"
I run through the usual Terraform workflow:
$ terraform fmt
$ terraform validate
$ terraform init
$ terraform plan -out=tfplan
$ terraform apply "tfplan"
Once the provisioning is complete I can head on over to Azure:
- Go to Entra ID
- Click on App registrations
- Find and click on the registration with name
github-${var.github_repository_name}
, i.e. this depends on what your repository name is (in my case it is namedgithub-demo
) - Click on Certificates & secrets
- Click on Federated credentials
You should see a list similar to this:
Verify that it works#
To verify that the credentials work I create the following workflow in my GitHub repository and commit it to the main
branch:
name: Access Azure
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
read:
runs-on: ubuntu-latest
steps:
- uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az group list
I use the GitHub CLI to trigger the workflow (i.e. I issue a workflow_dispatch
event):
$ gh workflow run azure.yaml
✓ Created workflow_dispatch event for azure.yaml at main
I can check the status of the workflow2:
$ gh run view --job=25844778142
✓ main Access Azure · 9385784639
Triggered via workflow_dispatch about 1 minute ago
✓ read in 26s (ID 25844778142)
✓ Set up job
✓ Pre Run azure/login@v1
✓ Run azure/login@v1
✓ Run az group list
✓ Post Run azure/login@v1
✓ Complete job
It is clear that the connection worked! What would happen if I run the same workflow but trigger it from a branch I have not set up federated identity credentials for? Remember that I have federated identity credentials for three branches: main
, feature-1
and feature-2
. Let me find out:
$ git switch -c new-branch
$ git push -u origin new-branch
$ gh workflow run azure.yaml --ref new-branch
✓ Created workflow_dispatch event for azure.yaml at new-branch
Investigating the results of this run shows me that it did not work:
X new-branch Access Azure · 9385892770
Triggered via workflow_dispatch about 1 minute ago
JOBS
X read in 21s (ID 25845142153)
✓ Set up job
✓ Pre Run azure/login@v1
X Run azure/login@v1
- Run az group list
✓ Post Run azure/login@v1
✓ Complete job
I can also see an error message:
X AADSTS700213: No matching federated identity record found for presented assertion subject 'repo:mattias-fjellstrom/demo:ref:refs/heads/new-branch' [...]
This is good news!
Summary#
Federated identity credentials are great! No longer do you need to manage credentials and remember to rotate them every now and then. I hope you could also appreciate how easy it was to configure federated identity credentials. Something that might trip you up initially is that you need to set up federated identity credentials for each branch, tag, and environment (and also pull request) that you want to run workflows for. However, as I showed in this post this does not necessarily mean additional complexity in your Terraform configuration. Also, you will most likely start to appreciate this feature because it provides an extra layer of security.
The demo in this post highlighted how this works for Azure and GitHub Actions. There are more systems that allow you to use similar constructs. If this feature is available in the systems you work with, use them.
Remember that it is best practice to lock your providers to a specific version. This way you are in control of when this provider is updated. If you want to have a little more automation you could use the pessimistic constraint operator
~>
to allow either only the patch component increase, or both the minor and the patch component. ↩︎I got the job ID by first running
gh run view
, selecting the latest run, then I could see it in the output. ↩︎