Using OIDC with Reusable Workflows to Securely Access Cloud Resources
Using Reusable Workflows in GitHub Actions to standardize and security harden your deployment steps
Overview
OpenID Connect (OIDC) is great for accessing resources by exchanging short-lived tokens directly to the thing you are trying to authenticate with (often a cloud provider but doesn’t have to be!). GitHub Actions has several examples for using OIDC in workflows to be able to access resources like Azure, AWS, HashiCorp Vault, etc. Passwordless authentication is game-changing!
In GitHub Actions, Reusable workflows are also great for providing consistency to workflows within an organization. Also, they prevent code duplication and simplifies making changes to workflows.
These two features can be combined to provide a secure and consistent way to access cloud resources.
For example, what if there was a secret that was required in every single workflow (such as a key to access a private Maven/NuGet/npm/Docker/etc. feed)? When using reusable workflows, that secret has to exist on the caller workflow repo, not on the *called aka reusable workflow repo*. You either have to create an organization secret that has access to all repositories (which isn’t ideal since that means anyone with write access to a repository can write some code to access that secret), or you have to create a secret on each repository that uses the reusable workflow. This is where the magic of OIDC and reusable workflows meet!
This post will show you how to customize your subject claims on the GitHub repository pass in the reusable workflow to Azure to be able to authenticate to an Azure Key Vault and retrieve a secret.
The OIDC Subject Claim
Following the GitHub docs:
- Using
job_workflow_ref
:
- To create trust conditions based on reusable workflows, your cloud provider must support custom claims for
job_workflow_ref
. This allows your cloud provider to identify which repository the job originally came from.- For clouds that only support the standard claims (audience (
aud
) and subject (sub
)), you can use the API to customize the sub claim to includejob_workflow_ref
. For more information, see “About security hardening with OpenID Connect”. Support for custom claims is currently available for Google Cloud Platform and HashiCorp Vault.- Customizing the token claims:
- You can configure more granular trust conditions by customizing the subject (
sub
) claim that’s included with the JWT. For more information, see “About security hardening with OpenID Connect”.
- We can also see which claims are supported here: https://token.actions.githubusercontent.com/.well-known/openid-configuration
If you’re not an OIDC expert (don’t worry, I’m not either), this might not make a ton of sense, but don’t worry, let’s step through it.
Let’s first start by examining the subject (sub
) claim that GitHub Actions generates by default. We can print out the token by copying a bash script step or using a ready-made action to debug the OIDC token claims. The action is a Docker action, which can make it harder to run on some hosts, so I am including both examples here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
print-oidc-token:
runs-on: ubuntu-latest
permissions:
id-token: write # this is needed for oidc
contents: read # this is needed to clone repo
steps:
# debug using the action
- name: Debug OIDC Claims
uses: github/actions-oidc-debugger@main
with:
audience: '${{ github.server_url }}/${{ github.repository_owner }}'
# print oidc token claims manually
- name: print oidc token claims
run: |
IDTOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL" -H "Accept: application/json; api-version=2.0" -H "Content-Type: application/json" | jq -r '.value')
jwtd() {
if [[ -x $(command -v jq) ]]; then
jq -R 'split(".") | .[1] | @base64d | fromjson' <<< "${1}" > jwt_claims.json
cat jwt_claims.json
echo ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL}}
fi
}
jwtd $IDTOKEN
By default, the sub
of the OIDC token that GitHub Actions generates just looks something like this:
1
"sub": "repo:joshjohanning-org/standard-oidc-claim-demo:ref:refs/heads/main"
Notice that there isn’t anything special in there; just the repository that is running the workflow and the ref (or if you were doing a deployment, the deployment environment would show here).
We want to customize this where that our cloud provider (Azure in my example) can authenticate with our approved reusable workflow.
For AWS, the docs say: “Note: Support for custom claims for OIDC is unavailable in AWS.” This is saying you can’t create any custom claims (discussed here), but you can still customize the subject (
sub
) claim as I show later in this post. AWS’s docs and section in theaws/configure-aws-credentials
action has more information on this.
Customizing the Subject Claim in GitHub
We can customize the subject claim using the API, but more easily, we can use @tspascoal’s gh-oidc-sub gh
CLI extension:
- Install the
gh
CLI extension:1
gh extensions install tspascoal/gh-oidc-sub
- Let’s verify the existing claims (if it is customized or using default):
1
gh oidc-sub get --repo joshjohanning-org/oidc-claims-demo
- If nothing has been changed yet at the repo or org level, it should look like this:
1 2 3
{ "use_default": true }
Note that if it isn’t using the default, you can set it back to the default by running:
1
gh oidc-sub usedefault --repo joshjohanning-org/oidc-claims-demo
- Then, we can run the following command to customize the subject claim to include the
job_workflow_ref
:1
gh oidc-sub set --repo joshjohanning-org/oidc-claims-demo --subs "job_workflow_ref"
- This just returns
{}
, but let’s run theget
command again to verify that it was set:1
gh oidc-sub get --repo joshjohanning-org/oidc-claims-demo
- Now, the output should look like this:
1 2 3 4 5 6
{ "use_default": false, "include_claim_keys": [ "job_workflow_ref" ] }
- If we run the step to print out the OIDC token claims as discussed in the section above, we will see:
1
"sub": "job_workflow_ref:joshjohanning-org/oidc-claims-demo/.github/workflows/azure-oidc-demo.yml@refs/heads/main"
- With the subject claim customized, we can require all interactions with Azure use this reusable workflow 🎉
Using the Subject in Azure
Now that we have the subject claim customized on the GitHub repository, we can use it with the federated credential on the Azure side.
- In AAD (Entra ID), navigate to the app registration that you want to use to authenticate to Azure
- Under “Certificates & secrets”, add a new “Federated credential”
- You can select “GitHub” as the federated credential scenario, but it’s easier to just use “Other issuer”
- For the issuer, use:
https://token.actions.githubusercontent.com
- For the subject identifier, use something like:
1
job_workflow_ref:joshjohanning-org/reusable-workflows/.github/workflows/azure-oidc-sample.yml@refs/tags/v1
You will have to decide if you want to use a tag or a branch for the ref, and in Azure, you can’t use wildcards (in AWS you can!). I prefer tags for consistency, but you can use a branch if you simply want your users to refer to
@main
to always have the latest. If referencing a branch, use:refs/heads/main
- It should look something like this: Federated credential in Azure using job_workflow_ref
Note the maximum number of federated credentials per app registration from the Azure docs:
A maximum of 20 federated identity credentials can be added to an application or user-assigned managed identity.
Configuring the Reusable Workflow
So far we have updated the subject claim in GitHub and configured the federated credential in Azure. Now, we will create a reusable workflow in GitHub and test out if we can 1) successfully authenticate using the approved @v1
tag above, and 2) if it fails when it should when using another tag or any other reusable workflow.
Here’s my calling workflow (i.e.: the workflow in my “app” repo):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
name: Azure OIDC Demo
on:
push:
branches: main
pull_request:
branches: main
workflow_dispatch:
jobs:
azure:
uses: joshjohanning-org/reusable-workflows/.github/workflows/azure-oidc-sample.yml@v1 # v1 is 'approved' workflow
with:
keyvault: josh-key-vault-test
For security purposes, if you need to fetch an OIDC token generated within a reusable (called) workflow that is outside your enterprise/organization, the
id-token: write
needs to be explicitly set at the caller workflow level or in the specific job that calls the reusable workflow.
And here’s the called workflow (i.e.: the workflow in my “reusable workflows” repo):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
name: azure-oidc-sample
on:
workflow_call:
keyvault:
description: name of the keyvault
type: string
default: josh-key-vault-test
jobs:
login:
runs-on: ${{ inputs.runs-on }}
permissions:
id-token: write # this is needed for oidc
contents: read # this is needed to clone repo
steps:
- uses: actions/checkout@v4
# logging in with OIDC
- name: 'Az CLI login'
uses: azure/login@v1
with:
client-id: d951ac80-75f2-446a-aca6-cd53a68611f0
tenant-id: e9846558-c4f0-4312-a89e-ebebe80779a1
subscription-id: 2e9bfb26-ca29-44f5-8920-72c1b0b37188
- name: print azure subscription info
run: |
az account show
az account show | jq ".id"
- name: get all az keyvault secrets
run: |
for secret_name in $(az keyvault secret list --vault-name ${{ inputs.keyvault }} --query "[].{name:name}" --output tsv); do
secret_value=$(az keyvault secret show --vault-name "${{ inputs.keyvault }}" --name $secret_name --query value -o tsv)
echo "::add-mask::$secret_value"
echo "$secret_name=$secret_value" >> $GITHUB_ENV
done
- name: testing secrets
run: |
echo "echoing as secret: ${{ secrets.my-secret }}" # doesn't work
echo "echoing as env: ${{ env.my-secret }}" # works
When running the workflow, we can see that it successfully logs in and fetches the secrets from the keyvault:
Using OIDC in GitHub Actions to authenticate to Azure and retrieve secrets from a Key Vault
If we try to be sneaky and use a different reusable workflow, a different tag/branch, or no reusable workflow at all, it will fail.
Here’s an example where I tried to use a different reusable workflow and it fails:
1
2
3
4
5
jobs:
azure:
uses: joshjohanning-org/reusable-workflows/.github/workflows/azure-oidc-sample-not-approved.yml@oidc-sample-not-approved # v1 is 'approved' workflow
with:
keyvault: josh-key-vault-test
Failing to use OIDC to authenticate to Azure because I’m not using an approved reusable workflow
Summary
Often, I see that teams want to abstract and isolate their reusable workflows completely from the teams calling them. The team building the reusable workflows don’t want to require secrets to be stored in the calling repository for the sake of both reducing complexity and increasing security. There is the secrets: inherit
keyword that can be used to pass in all secrets and satisfy the complexity complain, but it doesn’t satisfy the security concern. Any repo-level or organization-level secret that exists in the repository can be accessed by anyone with write
permissions to the repo by creating a new workflow. There is a roadmap item to address these concerns, but there is no timeline for it yet.
However, using OIDC to authenticate to Azure and retrieve secrets from a Key Vault is a great way to solve this problem, especially if you’re already using an external key store like Azure Key Vault to manage your secrets. Where it really gets magically is when we combine OIDC and reusable workflows to create a secure and consistent reusable workflow that can be used across the organization without having to make secrets accessible to any other workflow. ✨