Authenticating GitHub Actions HTTP Requests with OpenID Connect

Having frictionless trust between your service and GitHub Actions, without long-living tokens

.

tl;dr: if you build a GitHub Action that is backed by a server of some sort, you can easily authenticate using the built-in OpenID Connect, instead of adding friction to your users with long-living tokens or a registration phase

Side projects can be challenging. They require significant effort to work on without procrastination, and can be difficult to complete properly. The simpler the project, the better. Additionally, gaining users for side projects can be challenging. Why would a user sign up for your service? That's a point of friction! So it would be great to avoid authentication whatsoever.

I recently started building a side project called Benchy. It’s a (fairly) simple GitHub app that allows you to track performance over time and comment nice summaries in GitHub comments:

Figure #0

When building Benchy, I decided to have the smallest friction possible in the integration phase. At first, I built it by leveraging GitHub Actions Artifacts, so no 3rd party server even needed. But commenting was not allowed on non-maintainers on a repo, so a 3rd party server was required.

So how can I avoid authentication with my 3rd party server?

  • Can a token be a signed data so we don’t store anything in the database?
  • Can we make the users create their own tokens in plain text?

Turns out that I can use another way, when I target GitHub Actions as my main platform for integration:

GitHub Actions OIDC

OpenID Connect is an authentication protocol that allows users to log in to multiple websites using one set of credentials. It establishes trust because it allows the user to authenticate with a trusted identity provider, rather than needing to create and remember new login information for each individual website.

GitHub Actions has some 3rd party actions to sign in to AWS, to Azure, etc — all without having a long-living token. For example, with AWS, you can try authenticating to a certain role using the configure-aws-credentials GitHub action.

Any workflow in GitHub Actions can generate an OpenID Connect token that has a bunch of information about the workflow being run: the full repository name, the actor (aka user that initiated the action), the workflow type and name, etc.

The token itself is a JWT, and you can verify it using GitHub Actions OpenID public JWK address. More on that in a bit. Doing that, allows you to receive that token in your system, validate it, and actually use the signed payload.

All this, without needing the users to ever sign up into your system or configure anything in their secret store!

The GitHub Actions OIDC docs don’t really explain how to do that practically on your back-end, so I decided to write a short piece that explains how to do that.

Generating an OIDC token

Generating an OIDC token is something that happens within the GitHub Actions runner. You can do that by having the following script:

import * as core from '@actions/core';

// ...

// Grab the token
const token = await core.getIDToken();
// send it in the Authorization header:
// fetch(`https://my-service`, { headers: { ... } })

Validating the OIDC token

To validate the OIDC token, you can use the wonderful jose library:

import { createRemoteJWKSet, jwtVerify } from 'jose';

export async function validateToken(token: string): Promise<Record<string, unknown>> {
  const jwks = createRemoteJWKSet(
    new URL("https://token.actions.githubusercontent.com/.well-known/jwks")
  );
  const { payload } = await jwtVerify(token, jwks, {
    issuer: "https://token.actions.githubusercontent.com",
  });
  return payload;
}

Jose makes it pretty easy to validate! All we need to do is to point it to the public keys, which are stored in https://token.actions.githubusercontent.com/.well-known/jwks. The issuer of these tokens will be https://tokens.actions.githubusercontent.com.

Remember: even though the payload is verified, it still doesn’t mean it can change its structure over time. I recommend parsing the payload using @effect/schema, zod or similar libraries in order to fail early in your app when the data structure changes.

So if you want to measure benchmarks continuously, check out the Benchy action on GitHub. It is so easy to integrate and try out, it’s almost a crime not to. It’s not ready whatsoever but hey, let’s break it together.

Thanks to Amit Dahan and Yonatan Mevorach