Skip to main content

Azure Federated Identity Credentials for GitHub

·1885 words·9 mins
Azure Github Terraform

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.

Note: what I will demonstrate in this post is not brand new, it has actually existed for a while. The purpose of this post is to present a way of how to set this up using Terraform with ease.

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:

workload identity workflow
Image obtained from the official Azure documentation for workload identity federation ( link)

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:

  1. GitHub Actions workflow requests a token from GitHub
  2. GitHub issues a token to the workflow
  3. The workflow sends the token to Entra ID (Azure Active Directory in the image)
  4. Entra ID validates the token with GitHub
  5. Entra ID issues an access token to the workflow
  6. 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
#

Note that there is a limit of 20 federated identity credentials for a given app registration. Keep this in mind when following this demo, because there is no check to make sure you do not go over 20 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:

  1. Go to Entra ID
  2. Click on App registrations
  3. 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 named github-demo)
  4. Click on Certificates & secrets
  5. Click on Federated credentials

You should see a list similar to this:

azure federated identity credentials
List of federated identity credentials

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.


  1. 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. ↩︎

  2. I got the job ID by first running gh run view, selecting the latest run, then I could see it in the output. ↩︎

Mattias Fjellström
Author
Mattias Fjellström
Cloud architect consultant and an HashiCorp Ambassador