Secretless TLS client certificates in GitHub Actions
Carl Tashian
TL;DR With GitHub Actions OIDC tokens and Smallstep Certificate Manager, you can access protected internal resources like cloud services, databases, websites, or Kubernetes clusters from inside GitHub Actions, using short-lived TLS certificates. Best of all, this approach requires no hard-coded secrets, and just a few lines of YAML.
Introduction
What are GitHub Actions?
First, a quick refresher on GitHub Actions. GitHub’s Actions lets GitHub maintainers define and automate various workflows that act on GitHub repositories. Actions are hosted inside repositories and defined in YAML files like this one. After launching in 2018, GitHub Actions have become very popular for Continuous Integration (CI) tests and other build and deployment pipelines—especially Continuous Deployment (CD) to the cloud. GitHub Actions has become very popular in recent years, eclipsing third-party tools like Jenkins or Travis CI that used to handle these tasks.
At Smallstep, we use GitHub Actions heavily, to run tests, scan for security vulnerabilities, build binaries for step-ca
and step-cli
, sign binaries, and to distribute our releases to places like Docker Hub, Homebrew, and other software repositories.
👾 Here’s an extreme use case of GitHub Actions GitHub Actions have a huge variety of use cases, only limited by the imagination. Simon Willison put together a GitHub Action project (that popped on Hacker News last week) which performs automatic AI transcription of YouTube videos. You create a GitHub Issue on his repo with a video link, and the action runs, extracts the captions, and finally posts a comment on the issue with the extracted captions.
What are GitHub Action OIDC tokens?
In November 2021, GitHub vastly improved their integration with cloud providers like AWS and Google Cloud Platform when they introduced OpenID Connect (OIDC) tokens for GitHub Actions. While these tokens are pitched as a security hardening feature, they also simplify any workflow that needs to access external cloud services by removing the need for storing hard-coded secrets in GitHub.
An OIDC token is bit like a digital driver’s license. It’s a small blob of JSON with some identifying information (”claims”) signed by a trusted central authority (like GitHub). The magic of these tokens is that they can be passed around and easily verified by any piece of software. It’s part of the same technology (OAuth) that makes single sign-on (SSO) applications possible.
GitHub’s OIDC tokens identify a GitHub Actions Workflow run. So, if you configure your cloud service to accept GitHub’s tokens for authentication, then you don’t need to hard-code any of your cloud credentials in GitHub! The ephemeral OIDC token can be used instead.
How does a Workflow get a GitHub OIDC token?
Every GitHub Workflow run comes with a secret key that can be used to fetch a GitHub OIDC token for the run, via GitHub’s token server. The process for retrieving this token is a little different from an OAuth SSO flow that you may know. Instead of going through a login flow with a web browser, you just need to run one curl command from inside a Workflow to get a token for the Workflow:
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://StepCATokenExchange" | jq -r .value)
I tried this out with a test workflow, and here’s the payload of the token I got back:
{ "jti": "d4EXAMPLE11", "sub": "repo:tashian/oidctest:ref:refs/heads/main", "aud": "api://StepCATokenExchange", "ref": "refs/heads/main", "sha": "488EXAMPLE40218796a21fa24f4fc55cbdce6ff", "repository": "tashian/oidctest", "repository_owner": "tashian", "repository_owner_id": "4EXAMPLE3", "run_id": "3EXAMPLE08", "run_number": "21", "run_attempt": "3", "repository_visibility": "private", "repository_id": "5EXAMPLE8", "actor_id": "4EXAMPLE3", "actor": "tashian", "workflow": "GitHub OIDC Demo", "head_ref": "", "base_ref": "", "event_name": "push", "ref_type": "branch", "job_workflow_ref": "tashian/oidctest/.github/workflows/oidc-demo.yaml@refs/heads/main", "iss": "https://token.actions.githubusercontent.com", "nbf": 1665009326, "exp": 1665010226, "iat": 1665009926 }
There’s a lot of identifying information here. This token is bound to the workflow, the repo, the action that triggered the workflow, and the workflow run itself.
Tutorial: Get a TLS client certificate in a Workflow
Now let’s use the token to get a TLS certificate from a Smallstep Certificate Manager CA:
- To follow along, you’ll need to create a Certificate Manager CA if you haven’t already.
- Download and install the Step CLI tool, which you’ll use to manage your CA.
- Connect your client to your CA. You’ll find the command for this the Quick Actions section in your authority page in Certificate Manager. Hang on to your CA URL and fingerprint, because you’ll need them again in GitHub.
- Add a trust relationship between GitHub and your CA, using an OpenID Connect provisioner:
You can use any Client ID here, it just needs to match the one you use in the request to GitHub's token server, below.step ca provisioner add github --type OIDC \ --configuration-endpoint https://token.actions.githubusercontent.com/.well-known/openid-configuration \ --client-id "api://SmallstepCAProvisioner"
- Next, in GitHub, create a test repository. Add the following to
.github/workflows/oidc-demo.yaml
:name: GitHub OIDC Demo run-name: ${{ github.actor }} is testing OIDC to X.509 Certs 🚀 on: [push] permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout env: CA_URL: https://example.your.ca.smallstep.com CA_FINGERPRINT: c8da28e620ecEXAMPLEc405d92d7350dbec351cef3e4f6a6d2fc9518387ab OIDC_CLIENT_ID: api://SmallstepCAProvisioner jobs: OIDC-to-X509: runs-on: ubuntu-latest steps: - name: CA bootstrap run: | curl -LO https://dl.step.sm/gh-release/cli/gh-release-header/v0.23.1/step-cli_0.23.1_amd64.deb sudo dpkg -i step-cli_0.23.1_amd64.deb step ca bootstrap --ca-url $CA_URL --fingerprint $CA_FINGERPRINT - name: Get a certificate run: | TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$OIDC_CLIENT_ID" | jq -r .value) curl -sLO https://token.actions.githubusercontent.com/.well-known/jwks echo $TOKEN | step crypto jwt verify \ --jwks jwks \ --aud $OIDC_CLIENT_ID \ --iss "https://token.actions.githubusercontent.com" SUBSCRIBER=$(echo $TOKEN | step crypto jwt inspect --insecure | jq -r .payload.sub) step ca certificate $SUBSCRIBER github.crt github.key \ --provisioner github \ --token "$TOKEN" \ --not-after 1h step certificate inspect github.crt
- Update the
CA_URL
andCA_FINGERPRINT
values to match your Certificate Manager CA. Note also that theOIDC_CLIENT_ID
must match whatever client ID you originally configured in your OIDC provisioner configuration. - Now push the YAML to GitHub, and watch the action run. With any luck, you’ll have a certificate (
github.crt
) and private key (github.key
) at the end of the worker run! This short-lived certificate will be valid for an hour (the same duration as the token).
Passing the mic to you...
There’s a couple of next steps you’ll need to take in order to get this setup ready for a production environment.
- Authorization: In this example, any Workflow on GitHub could use our CA to get a certificate. Obviously that’s what we’d want. In a production setup, we’d want to make sure the issuance of certificates is limited to tokens where the
job_workflow_ref
claim matches my specific repo, workflow, and branch ref:tashian/oidctest/.github/workflows/oidc-demo.yaml@refs/heads/main
. One way to do this in Certificate Manager is with a custom certificate Template—associated with the CA provisioner—that will check the token for you. - Certificate Properties: By default, the Subject Common Name you’ll get back on your certificate will contain the sub from the token, eg.
repo:tashian/oidctest:ref:refs/heads/main
. But you may need different subject names or Subject Alternative Names (SANs) to identify your workflow. Or, you may want a shorter certificate validity period or other customizations. These are also available in Certificate Manager via custom templates, or via our Inventories feature. - Service configuration: Finally, you’ll need to configure whatever service you want your Workflow to connect to. Your service will need to trust your Certificate Manager CA for the purpose of client authentication. Usually this means downloading your root certificate from your Certificate Manager CA, and configuring your service to require and verify client certificates using your root CA PEM file.
Once these bits are done, you can extend the workflow to connect to your service and perform actions from there.
What else is possible?
If you haven't already, you can sign up and explore Smallstep Certificate Manager - after all, hardening your environments starts here. From there, you can use GitHub OIDC and Certificate Manager together in the following ways:
- Cross-repo access without hard-coded Git credentials or SSH keys: A similar workflow could be used to get an SSH certificate, for accessing another (private) repository on GitHub Many Actions use SSH keys to access another private GitHub repository. If your organization is using GitHub Enterprise, the Action could get itself a short-lived SSH certificate instead. These certificates can be linked to a specific GitHub user name. See GitHub’s documentation About SSH certificate authorities.
- Database authentication: PostgreSQL, MySQL, MongoDB, Redis, and many other databases and services that support client certificate authentication, but don’t support OIDC.
- Accessing internal websites: With mutual TLS configured on a reverse proxy server, you can protect your internal websites and services, and still access them via GitHub Workflows.
- Code Signing certificates: You could use this workflow to get an X.509 certificate for the purpose of code signing. While you could do this with Certificate Manager, you should also consider sigstore. They have created Fulcio and a GitHub Action dedicated to code signing, which may be a better fit for that application.
Ref: GitHub’s Documentation for GH Actions OIDC
Carl Tashian (Website, LinkedIn) is an engineer, writer, exec coach, and startup all-rounder. He's currently an Offroad Engineer at Smallstep. He co-founded and built the engineering team at Trove, and he wrote the code that opens your Zipcar. He lives in San Francisco with his wife Siobhan and he loves to play the modular synthesizer 🎛️🎚️