Implementing a custom GitHub token broker
Implementing a custom GitHub token broker to exchange GitHub Actions OIDC tokens for GitHub access tokens using C# and TypeScript.

With the recent surge in supply chain attacks targeting open source projects, I wanted to improve the security of my GitHub Actions workflows, and by extension my GitHub account, by implementing a custom GitHub token broker to exchange GitHub Actions OIDC tokens for GitHub access tokens.
In this blog post I’ll cover the background motivation for why I decided to implement a token broker, how it works, and how you can implement your own GitHub token broker based on a sample GitHub repository I’ve put together.
Background
Recently there has been a surge in supply chain attacks targeting open source projects. Projects are indirectly targeted through their dependencies, with malicious code published to package managers such as npm. The threat actor’s aim is to achieve code execution through installation on either developers’ local machines or through CI workflows in systems such as GitHub Actions itself.
Once execution is achieved, malicious code executes with the goal of exfiltrating secrets, such as AWS and GitHub access tokens, to remote command-and-control (C2) servers to allow the attacker to further compromise a project and its users.
Notable recent examples of these supply chain attacks have included CVE-2026-33634, which
targeted Trivy, and CVE-2026-45321, which targeted packages published by @tanstack
in npm. Both of these attacks were achieved through the publication of malicious code to Docker Hub, GitHub Actions
and/or npm, which then spread to their users through CI/CD systems via dependency updates using tools like
dependabot and renovate.
I like to think of myself as fairly security-conscious, but I was caught out by the Trivy compromise and merged a pull request containing one of the compromised versions of Trivy. Luckily, I only run Trivy overnight on a schedule, and the compromised code never actually ran in any of my GitHub Actions workflows. I was lucky.
After the TanStack compromise, I started to think about how I could improve the security of my GitHub Actions workflows so that if malicious code were to execute in one of my GitHub repositories in the future I could minimise the blast radius of any damage if a GitHub secret was exfiltrated by a malicious third party.
For a long time, I’ve relied on GitHub Actions secrets to store secrets such as GitHub access
tokens to use in my GitHub Actions workflows. The GITHUB_TOKEN built-in to GitHub Actions is
the go-to solution for authenticating with the GitHub API in many scenarios, but it has some limitations. The
primary limitation is that it is only valid in the scope of the repository it is executing in - it also has
restrictions regarding the triggering of other workflows, which can make release orchestration difficult to manage.
To work around these restrictions, I’ve used GitHub apps and personal access tokens in the past, but to use these in GitHub Actions you need to store their secrets somewhere, which I used to do by using GitHub Actions secrets. However, if a malicious actor is able to execute in one of your GitHub Actions workflows and extract these secrets, they would be able to pivot to other repositories and potentially compromise significant portions of your user account and/or GitHub organisation.
Tools such as zizmor can help to mitigate this risk by scanning your GitHub Actions workflows and
highlighting code patterns that can be refactored or removed to minimise risk, and npm v12 will
disable npm install scripts by default, but ultimately the secrets are still present in the workflows.
To mitigate this risk even further, I decided that I wanted to implement a custom GitHub token broker that would allow me to exchange short-lived secrets in my GitHub Actions workflows for a GitHub access token that that has only the permissions required for the task at hand and completely remove the GitHub Actions secrets.
To do this, I wanted to leverage GitHub Actions OIDC to build a broker to acquire secrets dynamically at runtime.
What is GitHub Actions OIDC?
GitHub Actions OpenID Connect (OIDC) is a feature of GitHub Actions that allows you to authenticate with cloud providers and other services without needing to store long-lived secrets in your GitHub Actions workflows.
When the id-token: write permission is available to a workflow job, you can acquire an OIDC token
from the GitHub Actions runtime via the ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN environment
variables. Making an HTTP request using these variables will return a JSON Web Token (JWT) that can be used to authenticate
with other services that support OIDC, such as AWS and Azure.
The JWT is cryptographically signed by GitHub and contains claims that securely identify the workflow job, repository
and more that you can use to make authentication and authorisation decisions. For example the repository_owner claim
can be used to determine the owner of the GitHub repository that acquired the JWT and the workflow claim can be used
to determine which GitHub Actions workflow it originated from. Many other claims are available that can
be used to make fine-grained authorisation decisions for how and when to allow custom logic for your automation. The
JWT is also short-lived and expires as soon as the GitHub Actions workflow completes.
GitHub Actions OIDC is supported by many well-known third-party services, but what if you want to use it in a custom integration? As ultimately the JWT is a standard OIDC token, you can use it to authenticate in custom code the same as any generic authentication solution, such as JWT Bearer Authentication.
Implementing the GitHub token broker
So how does the GitHub token broker work?
At a high level, the GitHub token broker is an ASP.NET Core Minimal API endpoint secured with JWT Bearer Authentication. For my own usage, the broker is embedded within my Costellobot GitHub application to process GitHub webhooks and other automation-related tasks.
The OIDC token is verified as being issued by GitHub and within its validity window. It also checks that it has a
repository_owner claim equal to my GitHub login (martincostello). If these conditions are met the request is
authenticated as part of the standard ASP.NET Core authentication pipeline using the JWT bearer authentication provider.
Once the request is authenticated, the request needs to be authorised. The token broker is configured with a dictionary of GitHub repository names, which are composed of a dictionary of what I’ve called a “token profile”. These token profiles specify a GitHub app or a named GitHub personal access token (PAT) to use, and a set of policy rules to determine which branches, events and workflows are allowed to acquire a GitHub access token in the context of that profile.
Here’s an example of a configured token profile named benchmarks:
"martincostello/costellobot": {
"benchmarks": {
"AppId": "3842668",
"AppPermissions": {
"contents": "write",
"issues": "write"
},
"Branches": ["*"],
"Events": [
"push",
"workflow_dispatch"
],
"TargetRepositories": [
"benchmarks",
"costellobot"
],
"Workflows": ["benchmark.yml"]
}
}
This effectively says:
The
benchmarkworkflow in themartincostello/costellobotrepository can use the GitHub app with the ID3842668to acquire a GitHub app installation access token withcontents: writeandissues: writepermissions when the workflow is triggered from any branch from either apushorworkflow_dispatchevent with access to themartincostello/costellobotandmartincostello/benchmarksrepositories.
If the profile is found for the current repository and authorisation is successful, the token broker will do one of two things, depending on whether the profile uses a GitHub app or a GitHub PAT. For a GitHub app, it will use the configured permissions and target repositories to create a GitHub app installation access token. For a GitHub PAT, it will make a request to an Azure Key Vault instance to retrieve the GitHub PAT secret value. The endpoint will then return the access token to the caller in the JSON response body.
The profiles support the principle of least privilege, ensuring an access token can only be acquired for the exact set of conditions where I intend the workflow to run and for what it needs to do.
By default, the generated GitHub app token will be scoped to the same repository that requested it. The profiles also allow for specifying an allowlist of GitHub Environments, tags and custom JWT claims to further restrict the conditions under which a token can be acquired.
Where possible, all the profiles use a GitHub app, but I added support for GitHub PATs for use cases where I need to orchestrate requests across multiple GitHub organisations/users and an appropriate app is not installed in all of these accounts.
I have intentionally decided not to allow the caller to specify the permissions and target repositories for the access token themselves, as this would allow a malicious workflow to potentially expand the scope of the access token beyond what was originally intended. If I need to change the permissions or target repositories for a profile, I can do this by updating the token broker configuration in Costellobot and re-deploying it.
Tokens issued for a GitHub app installation are valid for a period of one hour (more on this later).
Overall the response from the token broker endpoint will look something like this:
{
"token": "ghs_XXX",
"type": "app",
"appId": 1234567890,
"appPermissions": {
"contents": "write",
"pull_requests": "write"
},
"appRepositories": [
"repo-1",
"repo-2"
],
"appSlug": "my-github-app",
"installationId": 9876543210
}
or:
{
"token": "github_pat_XXX",
"type": "user"
}
Using the GitHub token broker
Now that there’s a token broker endpoint that can exchange GitHub Actions OIDC tokens for GitHub access tokens, how would we use it from a GitHub Actions workflow to get a token?
To make things as simple and idiomatic as possible, I implemented a custom GitHub Action that can
be used in any workflow to acquire a GitHub access token from the token broker. The action is implemented in TypeScript
and can be used in any of my repositories’ workflows. Below is an example of how to use the action in a workflow
to acquire a GitHub access token for the benchmarks profile described above:
- name: Get GitHub token
id: get-github-token
uses: martincostello/github-automation/actions/get-github-token@18237c275f9a773966e3e52dcabe5e7e559a9786 # get-github-token/v4.3.1
with:
profile-name: benchmarks
A subsequent workflow step can then use the token in the token output for its own needs. You can see a concrete
example of this in use in this workflow.
The action works by acquiring the OIDC token from the GitHub Actions runtime and making a request to the token broker
endpoint to exchange it for a GitHub access token for the specified profile name. The action will then set the access
token in the token output variable for use in subsequent steps in the workflow. It also masks the
returned token as a secret so that it is not logged in the GitHub Actions workflow job run’s logs.
When the token returned by the action is a GitHub app installation access token, the action will automatically
revoke the token when the workflow completes, so that it cannot be used again. This is done by defining a post step
for the custom action that will call the GitHub API’s DELETE /installation/token
endpoint when the workflow completes. This minimises the risk if a token is exfiltrated from the workflow by cutting
short the default one-hour lifetime of the token to the duration of the workflow run. In some cases, the tokens issued
to my GitHub Actions workflows might only be valid for less than a minute.
You can see diagrams of the overall architecture of the token broker and how it is used in a GitHub Actions workflow in the sample repository.
Creating your own GitHub token broker
If you found this approach interesting and want to implement your own GitHub token broker, I’ve put together a sample repository that contains a working implementation of a token broker and a custom GitHub Action to acquire a GitHub access token from it.
The sample repository is implemented in C# and TypeScript as it’s extracted from two other repositories containing my own implementations for my personal projects, but the concepts can be applied to any programming language and framework that supports OIDC and JWTs. The sample also contains tests for the token broker and the custom action for both positive and negative scenarios, so you can use it as a reference to see how it works in more depth.
Note that I’ve archived the repository as I do not intend to maintain it on an ongoing basis, but you are welcome to fork it and use it as a starting point for your own implementation.
If the approach I’m using evolves over time, you can always take a look at my Costellobot repository to see how I’m using it in my own GitHub Actions workflows for production-equivalent usage.
The sample implementation is provided for demonstration purposes only and is not intended to be used in production as-is. Feel free to use it as a reference for your own implementation, but you should review the code and make any necessary changes to ensure it meets your security and operational requirements.
Conclusion
Before I implemented my GitHub token broker, I was storing GitHub access tokens in GitHub Actions secrets, with the same broadly-scoped GitHub PAT being used across dozens of repositories.
Now with the GitHub token broker, I’ve been able to delete every single GitHub PAT from my GitHub Actions secrets. Where possible, every scenario now uses a GitHub app installation access token with the minimum permissions required scoped to only the repositories that need it. For the few scenarios where I still need to use a GitHub PAT, I have generated new tokens with the minimum scopes required and stored them in an Azure Key Vault instance. If any one token is compromised, I can revoke it and generate a new one without it affecting other repositories. The token broker also has a “kill-switch”, so if there’s ever an issue with the token broker itself, I can disable it and revoke all tokens it has issued.
I hope you’ve found this post interesting and that it has given you some ideas for how you can improve the security of your own GitHub Actions workflows and software supply chain.