Getting your GitHub Actions AWS security posture straightened out with OIDC

Doug Tangren
Making Meetup
Published in
6 min readJan 22, 2024
Photo by Roman Synkevych on Unsplash

Without a doubt at this point everyone should be deploying their AWS infrastructure through some form of automation. Tools to do so are both numerous and ubiquitous. At Meetup, we use GitHub Actions as a workflow automation tool and AWS SAM CLI to deploy the majority of our AWS infrastructure defined within templates. Because you’re giving machines the power to both hoist and tear down you’re sails it’s behooving to be mindful of the security guardrails you’ve set up to minimize what could possibly go wrong were those to fall into the wrong hands. Using a common but native approach authenticate with AWS from your GitHub Actions workflow like storing static AWS credentials within GitHub Actions opens the door to leaking access in one way or another which others can exploit. AWS actually has a recommendation on this and that is to use OIDC instead. This is also a recommendation of GitHub themselves. We’d like to share our recipe for how we manage our AWS infrastructure management automation securely.

While you can read more about how OIDC works here, I’ll spare the details in this post to focus instead on the steps you can take to bootstrap your own setup so you can get off to the races quickly.

First you’ll need to provision a few AWS resources that define and grant a trust model between your AWS account and GitHub’s servers. Below is a SAM template to do just that.

AWSTemplateFormatVersion: "2010-09-09"

Parameters:
Repository:
Type: String
#👇 OIDCProvider's are account level and there can be only one per URL but you may choose to provision more than one role for each GH repo
CreateProvider:
Type: String
Default: "true"
AllowedValues:
- "true"
- "false"

Conditions:
ShouldCreateProvider: !Equals
- !Ref CreateProvider
- "true"

# https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
Resources:
# 👇 create an OIDCProvider usedto trust GitHub's servers
# https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services#adding-the-identity-provider-to-aws
GitHubProvider:
Condition: ShouldCreateProvider
Type: AWS::IAM::OIDCProvider
Properties:
ClientIdList:
- "sts.amazonaws.com"
ThumbprintList:
# 👇 this is the aws formatted thumbprint of GitHub's open id jwks_uri peer tls certificate
# https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html
# we'll cover how to generate this list below
- "6938fd4d98bab03faadb97b34396831e3780aea1"
- "f879abce0008e4eb126e0097e46620f5aaae26ad"
- "1c58a3a8518e8759bf075b76b750d4f2df264fcd"
Url: "https://token.actions.githubusercontent.com"
#👇 create an IAM role for use within your GitHub Actions workflows
# we'll cover how below
Role:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub GithubActions-${Repository}
Policies:
# define custom IAM policies your role will need to execute your AWS interactions
# - PolicyName: !Sub "${AWS::StackName}-ddb-mgmt"
# PolicyDocument:
# Statement:
# - Effect: Allow
# Action:
# - dynamodb:*
# Resource: "*"
ManagedPolicyArns:
# list managed policies your role will need to execute your AWS interactions
# - arn:aws:iam::aws:policy/AWSCloudFormationFullAccess
#👇 how long this sessions using this role my persist
MaxSessionDuration: 3600
#👇 this is where the magic OIDC magic happens
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRoleWithWebIdentity
Principal:
Federated: !If
- ShouldCreateProvider
- !Ref GitHubProvider
- "token.actions.githubusercontent.com"
Condition:
StringLike:
# 👇 note we use meetup/${Repository} but you'll likely be using your GH org name!
token.actions.githubusercontent.com:sub: !Sub repo:meetup/${Repository}:*
Outputs:
Role:
Value: !GetAtt Role.Arn

We pair all our infrastructure templates with a samconfig.toml file that captures command line defaults which in our case looks like the following.

version = 0.1

[default.deploy.parameters]
stack_name = "gh-actions-meetup"
region = "us-east-1"
capabilities = "CAPABILITY_IAM CAPABILITY_NAMED_IAM"
fail_on_empty_changeset = false
parameter_overrides = [
# 👇 the name of the GitHub repository you're generating a Role for
"Repository=meetup"
]

We then deploy this infrastructure with sam deploy. You really only need do so this once per repository but you will likely evolving this as you identify which IAM role permissions you require when interacting with AWS in your GitHub Actions workflows.

The first thing to note is that this template conditionally creates an OIDCProvider. We have multiple code repositories which interact with the same AWS account and each requires its own IAM Role to assume so we only create OIDCProvider once because only one can be created per URL.

The next thing to note is that what our GitHub Actions workflows need to do with AWS will likely differ from what your GitHub Actions workflows need to do with AWS. As such I’ve elided our details but left a note on setting up your own IAM role’s permissions.

Lastly, the most non user-friendly component of getting OIDC set up with AWS is following this guide and we’ve learned it’s not something you do once. We have experience with GitHub changing it’s server TLS certificates which then generate different thumbprints. To facilitate getting a list of valid thumbprints easily and in a portable manner we wrote a script that looks as follows.

import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toSet;
import static java.util.stream.IntStream.range;

import java.net.URI;
import java.net.http.*;
import java.security.MessageDigest;
import java.security.cert.Certificate;
import java.util.*;
import java.util.stream.Stream;

void main() {
var client = HttpClient.newHttpClient();
var url = URI.create("https://token.actions.githubusercontent.com");
var prints =
range(0, 50) // run many times to capture potential variation
.mapToObj(
__ -> {
try {
return client
.send(
HttpRequest.newBuilder()
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.uri(url)
.build(),
HttpResponse.BodyHandlers.discarding())
.sslSession()
.map(
session -> {
try {
return session.getPeerCertificates();
} catch (Exception e) {
return new Certificate[] {};
}
})
.map(Arrays::stream)
//👇 capture the thumbprints of the peer TLS certificates
.map(certs -> certs.map(this::thumbprint).toList())
.stream();
} catch (Exception e) {
return Stream.<List<String>>empty();
}
})
.flatMap(identity())
.flatMap(List::stream);

//👇 print a list of distinct thumbprints
prints.collect(toSet()).stream().forEach(print -> System.out.println("- " + print));
}

String thumbprint(Certificate cert) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-1").digest(cert.getEncoded()));
} catch (Exception e) {
return "";
}
}

This script uses a newer preview feature Java 21 feature that simplifies creating entrypoints for Java applications. Look Ma, no classes!

We store script in a file called Thumbprint.java which we then run with java --source 21 --enable-preview Thumbprint.java. This automates the process of sourcing a list of possible AWS formatted thumbprints for GitHub’s servers.

At this point you should have an AWS IAM role you can start using in GitHub Actions. Here’s how. You will need a combination of the Configure AWS Credentials GitHub Actions plugin in combination of a GitHub actions permissions settings which you might not have been familiar with.

We recommend you store your IAM Role ARN in your GitHub repository’s Action variables. This will help you avoid needing to copy this ARN around your various workflow steps and many workflows which we’ve learned gets real onerous, real fast!

name: AWS Demo

on: push

jobs:
Demo:
runs-on: ubuntu-latest
# 👇 override the default permissions for the GitHub auth for this workflow run
permissions:
#👇 tell GitHub to provision an REQUIRED OIDC id token for this workflow run
id-token: write
# 👇 you'll need at least this to do a git checkout
contents: write
steps:
- uses: actions/checkout@v4
- name: configure aws credentials
uses: aws-actions/configure-aws-credentials@v4
with:
# 👇 resolve the IAM role provisioned the the same template above
# stored in you're repository vars, we call ours "MEETUP_PROD_ROLE_ARN"
# you could start with pasting your IAM role ARN here but you'll quickly
# learn you'll want to not do that
role-to-assume: ${{ vars.MEETUP_PROD_ROLE_ARN }}
# 👇 give your IAM session a name (creates an audit trail)
role-session-name: demo-${{ github.job }}-${{ github.run_id }}
aws-region: us-east-1
- name: Test
# 👇 if all the stars align you should see this print the `whoami` of aws
run: aws sts get-caller-identity

The important parts are telling GitHub actions to grant id-token permissions to your run and then providing a role-to-assume to your AWS credentials configuring workflow step with the IAM role provisioned above.

We’ve been using this with great success and minimal fuss to break our own bad historical habit of using static AWS credentials to authenticate the automation of AWS services and improve our security posture. We hope this will help you on your journey to improve yours.

--

--

Doug Tangren
Making Meetup

Meetuper, rusting at sea, partial to animal shaped clouds and short blocks of code ✍