This blog post is the first in a series of two in to explain how you can deploy keyless with GitHub Actions to AWS. This first part covers the basics of setting up OIDC integration between GitHub Actions and AWS. In the second more advanced practices are explained, such as deploying using GitHub Action shareable workflows with custom OIDC claims. This blog is practical and shows how to use OIDC with GitHub Actions to deploy to AWS. The same patterns apply to deploy to other OIDC enabled clouds like Google or Azure.
Deploy keyless with GitHub Actinns
Are you still using secrets to deploy from GitHub Actions to your cloud provider? Do you still define secrets in your workflow jobs and rotate them regularly? Do you know there is no need anymore to define secrets to connect to common cloud providers? You can avoid the need of using secrets when deploying with GitHub Actions to a provider supporting OpenID Connect (OIDC). New to OIDC, watch the talk “OAauth 2.0 and OpenID Connect in plain English”.
With OIDC you define a trust relation between GitHub Actions end your cloud, in our case AWS. You define a trusted relationship between your AWS account and the OIDC provider of GitHub Actions. And then define conditions under what circumstances an AWS IAM role can be assumed. New to assuming a role in AWS, watch this two minute tutorial.
Once the integration is set up, your workflows can obtain a short-lived token. This token is allowed to assume a role to access resources in AWS, based on the policies defined for the role. This post provides you with a practical guide on how to set up the required infrastructure resources with IaC, and shows a simple example to validate the setup.
Setup OIDC for GitHub Actions and AWS
GitHub provides detailed documentation for authenticating Actions to AWS that nicely describes all the required steps. It is highly recommended to read the article on GitHub to get a deep understanding. Since we love to automate everything we are not setting up the required resources manually. Instead we use Terraform to automate the creation of the resources.
The example in this blog is using the repository 040code/blog-oidc-github-actions-aws, you find a full example here. You can run the example by cloning or forking the repository and run the Terraform code by setting the variable repo
to your repository, e.g owner/repo
.
OIDC Provider
The first step is to define the OIDC provider. To define the OIDC provider we need to look-up the thumbprint for the GitHub Actions SSL certificate. The AWS web console can do this for you. The Terraform TLS provider has a data source that can do the look-up for us. Once we know the thumbprint, defining the OIDC provider is straightforward.
data "tls_certificate" "github_actions" {
url = "https://token.actions.githubusercontent.com"
}
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = data.tls_certificate.github_actions.certificates.*.sha1_fingerprint
}
Now run terrafrom apply
as a result the OIDC provider for GitHub is created in your AWS account, this is a global resource you only have to create it once per AWS account.
Next, define a role and trust relationship, this trust defines from which repository and under what condition the role can be assumed.
resource "aws_iam_role" "github_actions" {
name = "blog"
path = "/github-actions/"
assume_role_policy = data.aws_iam_policy_document.github_actions_trusted_identity.json
}
data "aws_iam_policy_document" "github_actions_trusted_identity" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "ForAllValues:StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = [
"sts.amazonaws.com",
"https://token.actions.githubusercontent.com"
]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:040code/blog-oidc-part-1:ref:refs/heads/main*"]
}
}
}
It is important that you take care defining the trust relation. One mistake, and you could open your AWS account to any repository on GitHub.
Assuming the role is allowed when all conditions are met. In general conditions are combined in a logical and
and values in the test with a logical or
. Ensure you are familiar with IAM conditions.
In the example above the iss
, aud
and sub
attributes are checked in the policy. The first two are straightforward. Checking the sub
requires care. When you accept any value in sub
or even don’t check the field. You will allow anyone access to your AWS role via GitHub Actions. And likely you don’t want to open our AWS account to the whole universe!
For example, the condition below allows any Action running in the repository 040code/blog-oidc-github-actions-aws
from any ref (branch / tag) to perform the assume role operation and access the resources granted via this role.
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:040code/blog-oidc-github-actions-aws*"]
}
By adding a condition like the one below, you can deny any pull request to assume the role. Similar checks can be done or allow or deny the ref, or context.
condition {
test = "StringNotLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:040code/blog-oidc-part-1::pull_request"]
}
It should be clear, that you need to take care of defining the conditions for which you define the trust. In part 2 you can read more about advanced subjects checks by customizing the sub
field of the JWT token.
The final step is to allow access to some resources via the created role. This blog post only shows the pattern by example. Remember that it is good practice to write policies with the least privileged access needed. For simplicity we have created a quite coarse-grained role for the blog. Time to define the required Terraform resources.
- An S3 bucket (no policies, no encryption in this example)
- A policy that grants the role created before access to the bucket.
resource "aws_s3_bucket" "blog" {
bucket = "040code-blog-oidc-github-actions-aws"
resource "aws_iam_role_policy" "s3" {
name = "s3-policy"
role = aws_iam_role.github_actions.name
policy = data.aws_iam_policy_document.s3.json
}
data "aws_iam_policy_document" "s3" {
statement {
actions = [
"s3:ListBucket",
"s3:GetObject",
"s3:PutOjbect"
]
resources = [
aws_s3_bucket.blog.arn, "${aws_s3_bucket.blog.arn}*"
]
}
}
For convenience add the following outputs to your terraform script. Those are handy when you create the workflow later. To apply this configuration, just run terraform apply
output "role" {
value = aws_iam_role.github_actions.arn
}
output "bucket" {
value = aws_s3_bucket.blog.name
}
✨ Test locally ✨
The role created can only be assumed via GitHub Actions, sometimes it is convenient to test from your own environment. This can be done by adding the following statement to the trust condition. After applying you can assume the role locally. Replace your the user_id
by your user id ARN.
statement {
actions = ["sts:AssumeRole"]
principals {
type = "AWS"
identifiers = ["arn:aws:iam::<aws_account_id>:user/<user_id>"]
}
}
Deploy without keys
Time to show this setup works, up to here no GitHub repository was needed. To test the setup, create a GitHub repository. Which can be a private or public one in any org or user space; it really does not matter. The easiest way is just to fork the example repository. Once you have created the repository, create a secret to hide you AWS Account ID. In this post we use a secret named AWS_ACCOUNT_ID
. The ID should not be a secret but at least you make it one small step harder for an attacker.
Earlier you have defined the OIDC provider in your AWS account, this provider is used to provider AWS keys based on the JWT token provided by the GitHub OIDC provider. AWS provides an action: aws-actions/configure-aws-credentials
to obtain an AWS STS token via OIDC. This action makes the whole process of obtaining short-lived secrets painless; you only need to add the action to your workflow and configure the role to assume. All secret handling is done by the action.
The workflow below brings it all together. The workflow shows how you can access an S3 bucket from GitHub Actions without configuring a secret.
name: S3
on:
workflow_dispatch:
push:
jobs:
deploy:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v3
with:
node-version: 16
- name: configure aws credentials
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions/blog
role-session-name: gh-actions
aws-region: eu-west-1
- name: deploy
run: |
npx cowsay -f ghostbusters "Running ${{ github.workflow }}" > message.txt
aws s3 cp message.txt s3://${{ github.repository_owner }}-${{ github.event.repository.name }}/${{ github.run_id }}.txt
rm message.txt
- name: check
run: |
aws s3 cp s3://${{ github.repository_owner }}-${{ github.event.repository.name }}/${{ github.run_id }}.txt result.txt
cat result.txt
Once you have pushed the workflow to your repository, a GitHub Action job starts running. The result shows some ascii art.
That was all, no keys needed anymore, no key rotation required. Time to stop using keys and start using OIDC. In part 2 we discuss a more advanced use-case in which you can use OIDC to deploy with a shared workflow.