Secretless connections from GitHub Actions to AWS using OIDC

No Comments

Imagine the following scenario: You set up your GitHub Actions in your repository. And it’s all cool until you want to access your cloud provider resources. Now you might be tempted to create an access key and secret access key, place it as a secret in your repository and forget about it. However, this can have drawbacks, for example your credentials could get leaked without your knowledge and your AWS account could be accessed by third parties. Additionally you probably haven’t thought about how to rotate these credentials. You might ask yourself: Isn’t there a better way to access your cloud resources without storing these credentials anywhere…?

Fear no more, GitHub and your favorite cloud provider got you covered!

When you wanted to access cloud resources (AWS, Azure, Google Cloud Platform/GCP) from your GitHub Actions, up until recently, you needed to store some sort of credentials (such as an access key and secret access key) as secrets in your repository. Not anymore!

GitHub announced the possibility to get rid of this type of credentials.
Instead, you can configure your GitHub Actions so that an OpenID Connect JSON Web Token (or short OIDC JWT) is generated by GitHub while your workflow runs.

In your cloud provider, you can register GitHub as an Identity Provider. This way, the JWT generated by GitHub is allowed to access your cloud account.

GitHub provides instructions on how to use this feature for AWS, Azure and GCP. In this blog post I will focus on AWS, as it’s the most widely used cloud provider and the one I’m most familiar with.

How the authentication between GitHub and AWS works

So before we get our hands dirty, let’s have a look at how the secretless connection using OIDC works:

diagram how github connects to aws using oidc

  1. In AWS (or your cloud provider of choice), create a trust relationship between your cloud role and the tokens issued by GitHub and used in your GitHub Actions workflow.
  2. A GitHub Action (such as aws-actions/configure-aws-credentials) can request a signed JWT with multiple claims during an action job/workflow run.
  3. GitHub OIDC provider issues a signed JWT with multiple claims to the github actions workflow.
  4. The action sends the JWT and the requested role to AWS.
  5. The JWT is validated in AWS and AWS sends a short-lived access token in exchange. With this token, it’s possible to access AWS resources.

The JWT contains claims e.g. the standard claims like “subject”. Each claim holds different information about the token itself. GitHub adds additional custom claims like ”reference”. You can find an example of the available claims here. These claims will later be used for the trust conditions.

Creating the trust

Now that we know how it works theoretically, we can start coding / setting it up in code.
The following infrastructure code examples are available in a demo repository. The examples are written in both AWS CDK and Terraform. You can also click through the AWS management console as well, but please don’t hold me accountable for your next AWS bill ;).

In order to access AWS resources from GitHub, we need to make sure we have

  • registered GitHub as a (trusted) OIDC Provider in AWS by
    • including under which conditions a GitHub token is considered trustworthy (trust condition),
  • created the resource itself,
  • created an AWS IAM role which can be assumed by your CI pipeline,
    • including a policy for that role which defines the AWS resources which are allowed to access.

Create OIDC Provider connection

First, lets create the OIDC Provider in AWS:

  • In IAM → Identity providers → Add provider
  • Provider URL: https://token.actions.githubusercontent.com
    Audience: sts.amazonaws.com

In CDK:

const githubOIDCProvider = new iam.OpenIdConnectProvider(
  this,
  "GithubActions",
  {
	url: "https://token.actions.githubusercontent.com",
	clientIds: ["sts.amazonaws.com"],
  }
);

This is how you tell AWS to allow tokens issued by GitHub to be accepted by AWS.
The audience/clientId “sts.amazonaws.com” is used by the official AWS GitHub Action aws-actions/configure-aws-credentials.

Create a resource

The next step is to create a resource in AWS for demonstrating the access during a workflow run.
I’ve chosen the service Parameter Store given that it’s cheap (most of the time free) and simple to store and retrieve a string. Give it a name (“hello_aws-gh-oidc” in this case) and a value and you’re good to go.

// Something to read for the pipeline :)
const helloParameter = new ssm.StringParameter(this, "HelloParameter", {
  description: `Sample value for demo purpose of project ${projectName}`,
  parameterName: `hello_${projectName}`,
  stringValue: "Hi from aws :wave:",
});

Create an IAM role

The last thing we need to do in our AWS account is to create an IAM role which is allowed to access the parameter you just created (permission policy) and is allowed to be assumed by GitHub (trust policy).

When using the GitHub OIDC approach, the trust policy is used to verify the content/claims of the issued JWT from GitHub.
The most common way is to verify the content of the subject and audience claim.

  1. The subject claim provides GitHub Actions job metadata, e.g. the branch name in case of a git push event. It looks different depending on which event triggered your job (GitHub provides example subjects here).
  2. The audience claim provides the URL of the organization or repository owner by default. The aws-actions/configure-aws-credentials action overrides the audience claim with the value “sts.amazonaws.com”

In CDK you define a secure IAM policy like this:

const githubActionsRole = new iam.Role(this, "GithubActionsRole", {
  // Trust policy
  assumedBy: new iam.WebIdentityPrincipal(
	githubOIDCProvider.openIdConnectProviderArn,
	{
	  StringEquals: {
		// Only allow tokens issued by aws-actions/configure-aws-credentials
		"token.actions.githubusercontent.com:aud": audience,
		// Only allow specified branches to assume this role
		"token.actions.githubusercontent.com:sub":
		  allowedBranchPatternToPush,
	  },
	}
  ),
  roleName: "aws-gh-oidc", // same as in .github/workflows/hello.yml
  description: `Role to assume from github actions pipeline of ${projectName}`,
});
 
// Permission policy
helloParameter.grantRead(githubActionsRole);

This results in the following secure IAM role trust policy:

{
    "Statement": [
        {
            // ...
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:sub": [
                        "repo:WtfJoke/aws-gh-oidc:ref:refs/heads/main",
                    ],
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                }
            }
        }
    ]
}

Only a push to the main branch of the repository named aws-gh-oidc from user WtfJoke can assume the role.

Please make sure you define your IAM role trust policies carefully.
Above is an example for a secure policy. Below is an example of an unsecure policy (the policy allows assuming the IAM role from other users repositories):

Unsecure IAM role trust policy:

{
    "Statement": [
        {
            // ...
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:sub": [
                        "repo:*/aws-gh-oidc:ref:refs/heads/main", // UNSECURE DO NOT USE
                    ],
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                }
            }
        }
    ]
}

A push to the main branch of a repository named aws-gh-oidc from any users can assume the role

Setting up the Pipeline/Workflow

Now that we have everything set up on AWS, the only thing left to do is to create a simple GitHub workflow which assumes the AWS role, and reads the parameter store parameter and prints the value. The final workflow looks like this:

name: Hello from AWS

on:
  push:
  
permissions:
  id-token: write

jobs:
  greeting:
    runs-on: ubuntu-latest

    steps:
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-region: eu-central-1
        role-to-assume: arn:aws:iam::589918074230:role/aws-gh-oidc

    - name: Print AWS SSM Parameter
      run: aws ssm get-parameter --name=hello_aws-gh-oidc --query Parameter.Value

    - name: Print assumed role
      run: aws sts get-caller-identity

We will go through all important steps in the workflow below.

Adding permissions

We need to allow the workflow to request a signed JWT from GitHub by setting the permissions:

permissions:
  id-token: write

Note: Depending on your use case, you might need additional permissions for your workflow. (see GitHub Docs for the complete list of default permissions).

Logging in at AWS

To exchange a GitHub JWT for a short-lived AWS access token, we can use the aws-actions/configure-aws-credentials action.
We need to pass the role (ARN) which we want to assume and have created earlier.

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v1
  with:
	aws-region: eu-central-1
	role-to-assume: arn:aws:iam::589918074230:role/aws-gh-oidc

Interacting with AWS resources

Finally we can interact with AWS by using the AWS CLI as usual.
In our case, we can read the parameter we created earlier and print it:

- name: Print AWS SSM Parameter
  run: aws ssm get-parameter --name=hello_aws-gh-oidc --query Parameter.Value

Running the secretless workflow

Now that we have set everything up and as soon as we push any commit to the repository, we should see a successful action run like the following:
github action prints hello world from aws parameter

What’s next?

By now, you have learned everything to get started using OIDC to access your cloud resources.
The most important takeaways:

  1. Favor using OIDC over regular credentials to access cloud resources.
  2. Double check the trust policies of your roles.

You can find all the code examples in this demo repository.

Although our use case was simple by just printing a single string parameter, only your imagination limits the things you can do in your workflow. 🌈😄

There are even more advanced use cases: As soon as a pull request (PR) is opened, your application is deployed in a temporary environment (just like vercel does) with the changes for the PR, so people can test the changes before it gets deployed to a live environment.

In contrast a push to the main branch is only allowed to deploy/update the existing live environment.
For different use cases, you can use different IAM roles and limit their access to the required minimum.

If you have comments or questions feel free to reach out.

Sources:
https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html
https://github.com/aws-actions/configure-aws-credentials#assuming-a-role
https://connect2id.com/learn/openid-connect
https://jwt.io/introduction

Manuel has been working for codecentric Karlsruhe since 2019. He has a strong focus on Java, Kotlin and Typescript. He is a big fan of clean code and refactorings. Currently he is into the topics Cloud and Chaos Engineering.

Comment

Your email address will not be published.