Breaking up with GitHub Personal Access Tokens
Every time you reach for a long-lived secret, you are wrong.
The impulse is good, but uh … having seen some of the recent shenanigans from yet another supply chain attack targeting long-lived secrets to exfiltrate, damn are they risky in that sneaky not-sure-if-better-than-passwords way.
Here’s what I’m trying to do.
flowchart LR
A(fa:fa-user Me<br>a human) -->|Push commit<br>to main branch| B(fab:fa-github Website<br>private repo)
B -->|Generate changes to<br>profile README.md| C(fab:fa-github Profile<br> public repo)
Pushing commits to another repo wasn’t complex at all. Yet I couldn’t shake the shameful feeling as I created another token that I’d have to remember to rotate (or worse, set it up to never expire). 🙈🙉🙊
Removing long-lived credentials in GitHub was way easier to do than I feared. The principle of least privilege is easy to embrace in theory, yet finding folks actually doing it is so much harder in practice.
Let’s do this the right way and never have to rotate a PAT, fine-grained or otherwise, again.
Making it work
To do this at work, we use and maintain a straightforward service (octo-sts ) that brokers identity using OIDC between repositories or organizations within GitHub (or anything else really). Since this is a simple use case, it’s a good place to start setting it up.
First, install the GitHub App . Since everything I use is in my personal username, that’s where it’s installed. Make sure to select a superset of repositories to use it on and permissions that it can federate.
My use case is simple. I selected the two repositories. I want to play around with it more in the near future, so I gave it many more permissions than needed. However, it can do basically anything that can be done over GitHub’s API, but federated from an identity provider. A good example is using Actions and Terraform to provide better integration with identity and access management in GitHub1.
Next, create a trust policy in the repository that’ll be granting some sort of permissions. In my case, it’s the profile repository.
1
2
3
4
5
6
7
issuer: https://token.actions.githubusercontent.com
subject: repo:some-natalie/website:ref:refs/heads/main
claim_pattern:
job_workflow_ref: some-natalie/website/.github/workflows/update-readme.yml@.*
permissions:
contents: write
Then I changed my workflow in the private (source) repository to ask for a short-lived token each time it runs.
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
jobs:
update-posts:
runs-on: ubuntu-latest
permissions:
id-token: write # needed to federate tokens
contents: read # needed to check out the website repo
steps:
- name: Federate token from Octo STS
uses: octo-sts/action@6177b4481c00308b3839969c3eca88c96a91775f # v1.0.0
id: octo-sts
with:
scope: some-natalie/some-natalie
identity: update-readme
# this uses GITHUB_TOKEN, the built-in short-lived token for this repo only
- name: Checkout website repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: website
# this uses the token we asked for to interact with this repo only
- name: Checkout profile repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: some-natalie/some-natalie
path: profile
token: ${{ steps.octo-sts.outputs.token }}
… and that’s it … OIDC is magic. 🪄
On the next new post, it just worked. No configuration weirdness, no tokens, no drama.
How it works
OIDC isn’t magic, but it feels like it
Easy GitHub App wrapper aside, this is a textbook OIDC transaction under the hood. Using the diagram above, here’s what happens each time it’s used.
- (only once) A human writes a trust policy, defining what an identity is allowed to do. Permission models vary by vendor, so check their documentation. My example one above (link ) is specific to GitHub. It allows
- Only the
.github/workflows/update-readme.yml
workflow (theclaim_pattern
field, with a GitHub specific field calledjob_workflow_ref
) - On the main branch of the
some-natalie/website
repo (thesubject
field) - To change content in the repo that this trust policy is placed in (the
permissions
field) - And only from the OIDC provider built in to GitHub Actions (the
issuer
field)
- Only the
- The workflow is going to ask the OIDC provider for authentication. When using GitHub Actions with their hosted OIDC provider (
token.actions.githubusercontent.com
), this is invisible to users. - If eligible, the OIDC provider gives them an auth token. When using GitHub Actions with their hosted OIDC provider (
token.actions.githubusercontent.com
), this is invisible to users. - The workflow runner asks to trade that token for another from Octo-STS.
- Octo-STS, as the identity exchange point, is going to look at the trust policy and see what it can do.
- If it’s in the trust policy, Octo-STS will give the workflow runner a token to use to
<do whatever it needs to do>
. - On completion, the workflow runner asks to revoke the token. By default, it lasts a few minutes only so even if this step doesn’t run it shouldn’t be around for a compromisingly long time.
Any and all of these parts are interchangeable. Don’t use GitHub? The same OIDC workflow works on any other CI system. The same is true for identity provider, cloud service, or any other OIDC compatible system. These parts can all be changed for whatever you’re currently using, self-hosted or a managed SaaS. 🪄
Parting thoughts
Well, this was easy. Now to slowly but surely working through deleting all the access tokens. The final directory structure to support this isn’t radically different from yesterday either.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.
├── website/
│ ├── .github/
│ │ ├── scripts/
│ │ │ ├── requirements.txt
│ │ │ └── latest-posts.py
│ │ └── workflows/
│ │ └── update-readme.yml
│ └── _posts/
│ ├── yyyy-mm-dd-titlehere.md
│ └── yyyy-mm-dd-another
└── profile/
├── .github/
│ └── chainguard/
│ └── update-readme.sts.yaml
└── README.md
guess who won’t be rotating a bunch of tokens anytime soon?!
Footnotes
-
Yes, I know about enterprise managed users in GitHub.com and the geo-restricted data-residency stamps. It doesn’t address the use case of having both public and private content within the same enterprise. This works like entitlements , but without the Ruby and with more OIDC. Here’s an example trust policy , but the workflows and Terraform are in a private repository. ↩