GitHub Actions recently implemented a feature that allows workflows to generate signed OpenID Connect tokens, which has exciting implications for anyone using GitHub Actions to manage resources in AWS.
This feature allows secure and seamless integration with AWS IAM and eliminates the need to store and rotate long-term AWS credentials in GitHub.
GitHub Actions is a popular and lightweight way to build automation into your software development workflow. Instead of maintaining a Jenkins cluster or a CodeBuild pipeline, developers can put a YAML workflow definition in a known location in their repo and be off and running.
Most build jobs finish by publishing an artifact of some kind - be it a compiled executable, a Docker image, or an AMI. Many will also deploy it to a running environment. Below we see an example of a job that builds a Docker image and publishes it to Amazon’s Elastic Container Registry (ECR).
Publishing a Docker image to ECR, or updating a running ECS service, or dropping a compiled binary in S3, or any other action touching AWS resources requires proper credentials. The job must be authenticated and authorized to perform these actions.
Until now, it was necessary to generate long-term credentials and store them somewhere accessible to the build job in GitHub. This often leads to checking AWS credentials into GitHub repos directly through sheer laziness/convenience (please, please don’t do this).
GitHub provides a way to store encrypted secrets securely, but even then - these are long-lived static credentials. Who’s rotating them and how often? I bet you aren’t.
The old approach to generating long-term credentials for GitHub Actions jobs is:
Even fairly recent tutorials on the subject promote this approach and automate it for ease of use. This approach does involve some IAM best practices - e.g., having the GitHub User assume-role to acquire permissions, and automating the configuration with code, but it still requires you to generate and store keys.
If you’re used to working with security in AWS, you should have some reservations about the big-picture design. IAM Users are generally reserved for living, breathing human beings, whereas we prefer to use IAM Roles for machines and applications.
IAM Users can have web console access and MFA tokens. These are not qualities we generally associate with build jobs. The documentation for IAM Roles sums up the distinction nicely:
…instead of being uniquely associated with one person, a role is intended to be assumable by anyone who needs it. Also, a role does not have standard long-term credentials such as a password or access keys associated with it. Instead, when you assume a role, it provides you with temporary security credentials for your role session.
Now that GitHub has added OpenID Connect (OIDC) support for GitHub Actions (as documented here on the GitHub Roadmap), we can now securely deploy to any cloud provider that supports OIDC (including AWS), using short-lived keys that are automatically rotated for each deployment.
The primary benefits are:
Now, your GitHub Actions job can acquire a JWT from the GitHub OIDC provider, which is a signed token including various details about the job (including what repo the action is running in).
If you’ve configured AWS IAM to trust the GitHub OICD provider, your job can exchange this JWT for short-lived AWS credentials that let it assume an IAM Role. With those credentials, your build job can use the AWS CLI or APIs directly to publish artifacts, deploy services, etc.
Enough talk. Let’s see some code. Here’s some IaC to help you implement this solution yourself.
You’ll need to configure IAM in your AWS account to trust tokens presented by the GitHub OIDC provider before your jobs can trade them for AWS credentials. AWS provides documentation for setting this up with the web console here, but we want to do this with code:
resource "aws_iam_openid_connect_provider" "githubOidc" {
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com"
]
thumbprint_list = ["a031c46782e6e6c662c2c87c76da9aa62ccabd8e"]
}
GithubOidc:
Type: AWS::IAM::OIDCProvider
Properties:
Url: https://token.actions.githubusercontent.com
ThumbprintList: [a031c46782e6e6c662c2c87c76da9aa62ccabd8e]
ClientIdList:
- sts.amazonaws.com
Note that in both flavors of the configuration, we specify the ClientIdList
as sts.amazonaws.com
- this will be the “audience” claim presented in the JWT when we use the official action to obtain credentials later on.
Now that IAM is configured to trust tokens presented by the GitHub OIDC provider, we now need to create an IAM Role for our build jobs to use and tell IAM that it can be assumed by anyone with a valid token from that provider.
data "aws_iam_policy_document" "github_allow" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.githubOidc.arn]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${GitHubOrg}/${GitHubRepo}:*"]
}
}
}
resource "aws_iam_role" "github_role" {
name = "GithubActionsRole"
assume_role_policy = data.aws_iam_policy_document.github_allow.json
}
Role:
Type: AWS::IAM::Role
Properties:
RoleName: GitHubActionsRole
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Action: sts:AssumeRoleWithWebIdentity
Principal:
Federated: !Ref GithubOidc
Condition:
StringLike:
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/${GitHubRepo}:*
Note how in both configuration blocks, we put a condition on the assume-role policy that the token.actions.githubusercontent.com:sub
claim must match a string with our GitHub org and repo in it.
This claim will be present in the JWT with a format like repo:my-org/my-repo:ref:refs/heads/my-branch
. Adding this condition ensures that only jobs running in your own GitHub repos can assume this role. Without it, any valid token from any GitHub action could get these AWS credentials.
You can further narrow scope with this condition to ensure that specific jobs can only be run by GitHub actions in specific repos, or specific branches. See additional information about hardening this configuration in the official docs from GitHub.
GitHub provides an official Action that we can use to assume a specific AWS IAM role in a workflow. The syntax is as simple as:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::123456789100:role/my-github-actions-role
aws-region: us-east-2
Here’s an example workflow that assumes the GitHubActionsRole we defined and uses the AWS CLI to prove that it has acquired valid credentials.
jobs:
build:
name: build
permissions:
id-token: write
contents: write
runs-on: ubuntu-18.04
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@master
with:
aws-region: us-east-2
role-to-assume: arn:aws:iam::12345678910:role/GitHubActionsRole
role-session-name: GithubActionsSession
- run: aws sts get-caller-identity
Here we just use aws sts-get-caller-identity
as proof that the assume-role flow worked, but you can substitute anything your assumed role has permission to do. e.g., if you’ve granted permission to write to S3, you could:
- run: aws s3 sync . s3://my-prod-website-bucket
If you’ve set everything up correctly, you should see output like the following in the GitHub Actions console when you run the proof of concept job above.
Hopefully, this has been a useful example of how to configure your GitHub Actions build jobs to securely and seamlessly use IAM Roles in AWS. If you’re using another cloud provider like GCP or Azure, or if you want to integrate it with HashiCorp Vault - check the official GitHub documentation here.