Engineering

Authenticating GitHub Actions without API keys

Eric Chiang

CTO / Founder

At Oblique, we’re building a modern service to define and manage authorization in your corporate environment, and an early “modern” decision we made was to be API-first. Everything a user sees in our UI should integrate seamlessly with RPC clients, configuration-as-code, and MCP clients.

This naturally leads into the question of how we authenticate those clients. Sure, we can mint a long-lived API key and allow-list your CI/CD system’s ~5000 CIDR ranges. But asking a user to copy and paste key material with a big “Keep this secret!” warning isn’t exactly what I’d describe as “seamless.”

Instead, let’s talk about workload identity and GitHub Action’s OpenID Connect support.

OpenID Connect

OpenID Connect is a protocol on top of OAuth 2.0 that allows third-party services to determine your email, account ID, and display name using standard fields (rather than every IdP implementing its own custom set of APIs). If I’m trying to figure out your email during an OAuth flow and I’m using OpenID Connect, the same code I write to log you into Google now works with Microsoft Entra, Keycloak, Dex, and Okta. Aren’t standards great?

At a low-level, the OpenID Connect token response includes a signed JWT from the IdP with pre-defined fields. Here’s an example payload from Google’s docs:

{
  "iss": "https://accounts.google.com",
  "azp": "1234987819200.apps.googleusercontent.com",
  "aud": "1234987819200.apps.googleusercontent.com",
  "sub": "10769150350006150715113082367",
  "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q",
  "hd": "example.com",
  "email": "jsmith@example.com",
  "email_verified": "true",
  "iat": 1353601026,
  "exp": 1353604926,
  "nonce": "0394852-3190485-2490358"
}

Now, if you give a developer a JWT signed by Google that says “this is eric@oblique.security,” they’re going to (ab)use them. Over the years, systems started accepting ID Tokens as a primary credential outside of OAuth2.0, and today you can authenticate directly to systems like Kubernetes, Vault, and AWS by presenting the ID Token itself from a command line or other non-browser system.

GitHub Actions credentials

GitHub Actions can request short-lived ID Tokens through an internal API exposed to the action. That token’s payload contains metadata signed by GitHub about the runtime environment and what triggered the run. Here’s an example payload with some fields omitted for brevity:

{
  "actor": "ericchiang",
  "actor_id": "2342749",
  "aud": "oblique.security",
  "exp": 1753783724,
  "iat": 1753762124,
  "iss": "https://token.actions.githubusercontent.com",
  "ref": "refs/heads/bash-script",
  "ref_protected": "false",
  "ref_type": "branch",
  "repository": "ericchiang/github-actions-oidc-example",
  "runner_environment": "github-hosted",
  "sub": "repo:ericchiang/github-actions-oidc-example:ref:refs/heads/bash-script"
}

For whatever reason, GitHub chose to implement Action identity as a single issuer for all workloads using global signing keys. This means that any Action can mint a valid signature for any system that trusts GitHub Actions. Yes, my dinky test repo can authenticate to your Kubernetes cluster. It hopefully isn’t authorized to do anything, but it’s up to the cluster to validate the action.

If the system that’s receiving the tokens doesn’t support custom field validation, you’re forced to pattern match the subject (“sub”) field, which follows the form “repo:<repo>:ref:<ref>” for branches. This can lead to some wonky config files, particularly if you want to authorize pull requests differently than protected branches. GitHub even provides organizational policies for custom subject templates which, while undoubtedly useful, seems especially cursed.

For Oblique, validating the token is easy using go-oidc. Here’s ~20 of lines of code to do that:

// Public keys for verifying signatures and other metadata are queried
// using this issuer URL.
const actionIssuer = "https://token.actions.githubusercontent.com"
provider, err := oidc.NewProvider(ctx, actionIssuer)
if err != nil {
	// ...
}
config := &oidc.Config{
	// MUST match the "audience" used when minting the token.
	ClientID: "oblique",
}
verifier := provider.Verifier(config)

// Verify the signature, issuer, audience, and expiry of the token.
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
	// ...
}

// For a complete list, see:
// https://docs.github.com/en/actions/reference/security/oidc#custom-claims-provided-by-github
var claims struct {
	// GitHub username that triggered the action.
	Actor string `json:"actor"`
	// The name of the branch in the form "refs/heads/<branch>".
	Ref string `json:"ref"`
	// The full name of the repository, of the form "<org>:<repo>".
	Repository string `json:"repository"`
	// Will be set if using deployment environments.
	Environment string `json:"environment"`
}
if err := idToken.Claims(&claims); err != nil {
	// ...
}
// Use claims to determine if the Action is authorized.

Based on the branch and repo fields, we can then make decisions to allow or deny access to our API without any copy and pasting of API keys.

Workload identity

It’s rare that a good security outcome provides a significantly better experience for users. Workload identity is absolutely one of those exceptions. You want users to be able to say “please let this action use Terraform to manage the service” without juggling API keys or figuring out how to store those credentials.

While I can (and will) gripe about some of the specifics about the implementation, we shouldn’t just ask for but expect identity primitives like this from any CI/CD, cloud, or infra product. If a platform lets you run code, it should be able to authenticate itself to an external system without pre-configuring an API key.