Skip to content
All posts
AWS OIDC GitHub Actions CI/CD IAM

Farewell Static Access Keys

Liran Aknin 9 min read

Introduction

Most CI/CD pipelines need to talk to a cloud: push an image, upload artifacts, deploy. Mine uploads a website to Amazon S3 and clears the CloudFront cache.

The common way to wire this up in AWS is to create an IAM user, give it the permissions it needs, and generate access keys. Those keys then get stored in the CI/CD platform, in GitHub Secrets, CircleCI Contexts, or similar.

Storing static secrets in third-party platforms means a breach of that platform is a breach of yours. High-profile incidents such as the CircleCI breach and the Codecov supply chain attack show how attractive CI/CD services are as targets. Another platform breach is a matter of time.

Rotating the keys is toil, and risky toil at that. Get one rotation wrong and deploys break, so in practice nobody rotates them often enough.

There is a better way. OpenID Connect (OIDC) replaces static credentials with short-lived tokens. These tokens are issued fresh on each workflow run, removing the need for long-lived secrets in CI/CD platforms. There is nothing left to steal.

In this post, I’ll show how I set up GitHub Actions to interact with AWS resources using OIDC. My use case is uploading files to a website hosted on S3 and clearing the CloudFront cache so the new content is served.

First, what OIDC actually does here.

Understanding OIDC

OpenID Connect (OIDC) is a protocol built on top of OAuth 2.0 that simplifies verifying the identity of users or services. Instead of relying on static credentials like passwords or API keys, OIDC uses tokens to provide secure access via short-lived credentials.

Using an OIDC Identity Provider allows you to manage external identities, such as users or services, to access your internal resources without the need to create and maintain separate internal user accounts.

To illustrate this, consider the analogy of a pass for a convention or conference. The event spans multiple days with various sessions, but access to specific sessions requires an appropriate pass. This pass contains your name (identity), your registration type (role or scope), and a unique QR code (short-lived credentials) to grant you access to the sessions. The pass is only valid for the event and time it’s issued, ensuring controlled, temporary access.

If we extend this analogy to our use case, GitHub workflows represent the attendees needing access to the specific event sessions (AWS resources). To gain access, the workflows request a pass from the GitHub OIDC provider, which issues an OIDC token in the form of a JSON Web Token (JWT). This token includes key details, called claims, about the specific request.

Let’s focus on the main ones:

  • iss: the issuer of the token (GitHub OIDC provider). This claim, along with other header parameters such as alg (algorithm) and kid (key ID), helps AWS validate the token’s origin and verify its authenticity.
  • sub: the subject of the token. Represents the unique workflow making the request. It includes metadata like the repository, branch, and triggering event.
  • aud: the audience of the token. Specifies who the token is intended for (defaults to the URL of the repository owner). In my case, because I use the official aws-actions/configure-aws-credentials action in my workflow, it must be set to sts.amazonaws.com. More on that later.

AWS then validates the OIDC token’s details against its predefined trust configuration. If the token’s claims match, AWS generates a temporary set of credentials. These credentials provide the workflow with secure, time-limited access to the specified resources.

Putting It All Together

Now the setup. I will use Terraform, my preferred tool for provisioning infrastructure. However, the same setup can be achieved using the AWS Management Console, the AWS Command Line Interface (CLI), or Tools for Windows PowerShell. Detailed instructions can be found in the official AWS documentation.

Note that AWS evaluates the standard sub and aud claims for the GitHub provider. It does not expose arbitrary customized GitHub claims as IAM condition keys.

Building the Trust

The first step is to add the GitHub OIDC provider as a trusted identity provider (IdP) in AWS. This is done by creating an OIDC provider entity in AWS IAM.

resource "aws_iam_openid_connect_provider" "github_actions_oidc_provider" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  # Since July 2023, AWS validates GitHub's OIDC provider against its trusted
  # root CAs, so a pinned thumbprint is no longer needed here. The AWS provider
  # makes thumbprint_list optional, so I leave it out.
}

The OIDC provider resource has two components that AWS uses during validation, and a third that used to be required:

  • url: the OIDC provider URL, which corresponds to the iss (issuer) claim in the token.
  • client_id_list: these correspond to the aud (audience) claim. In our case, it is set to sts.amazonaws.com.
  • thumbprint_list: historically a list of CA certificate thumbprints AWS used to validate the provider’s TLS certificate. As of July 2023, AWS validates GitHub’s provider against its library of trusted root CAs, so a pinned thumbprint is no longer needed for token.actions.githubusercontent.com. The AWS provider now makes this field optional, which is why the resource above leaves it out.

Creating a Role and Trust Policy

Once the OIDC provider is configured in AWS, the next step is to create an IAM role. Unlike a user, an IAM role does not have long-term credentials. Instead, it is a temporary identity assigned to a federated user, such as a GitHub workflow, after successful authentication by the identity provider (IdP).

The role defines the trust relationship, allowing the workflow to request temporary security credentials from AWS Security Token Service (STS) using a token issued by the IdP. These credentials grant access to AWS resources, with the scope of access governed by the policies attached to the role.

It is best practice to restrict which entities can assume the role by defining conditions in the trust policy. For example, you can use the token.actions.githubusercontent.com:sub condition key with string operators to limit access to a specific GitHub organization, repository, or branch. This ensures that only trusted repositories can request tokens for your resources. Additionally, IAM enforces that the token.actions.githubusercontent.com:sub condition key is present and that its value is not solely a wildcard (* or ?) or null. Partial wildcards (for example repo:GitHubOrg/GitHubRepo:*) are allowed. A bare wildcard is not. If this condition is missing or set to a lone wildcard, the request will fail.

resource "aws_iam_role" "github_actions_oidc_role" {
  name = "github-actions-oidc-role"

  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Principal" : {
          "Federated" : "${aws_iam_openid_connect_provider.github_actions_oidc_provider.arn}"
        },
        "Action" : "sts:AssumeRoleWithWebIdentity",
        "Condition" : {
          "StringEquals" : {
            "token.actions.githubusercontent.com:aud" : "sts.amazonaws.com"
          },
          # Restrict access to a specific GitHub repository and branch
          "StringLike" : {
            "token.actions.githubusercontent.com:sub" : "repo:GitHubOrg/GitHubRepo:ref:refs/heads/GitHubBranch"
          }
        }
      }
    ]
  })
}

Some Tips

Using Locals

Here’s an approach to restrict access to workflows from specific GitHub repositories, regardless of the branch, by leveraging Terraform locals.

# Environment-specific variable for the repository list
variable "github_repos" {
  type = list(string)
  default = [
    "repo1",
    "repo2",
    "repo3",
  ]
}

locals {
  repo_sub_list = [for repo in var.github_repos : "repo:GitHubUser/${repo}:*"]
}

# Use the local in the trust policy condition
"StringLike" : {
  "token.actions.githubusercontent.com:sub" : local.repo_sub_list
}
  • github_repos: a variable defining the list of GitHub repositories you want to allow.
  • repo_sub_list: a local variable that dynamically generates the sub claim conditions for each repository.
  • Condition: the StringLike operator ensures the role is limited to workflows from the specified repositories, regardless of the branch.

This approach becomes especially handy for managing resource access across multiple environments. It allows you to bind specific repositories to environments based on the required access. It also makes the configuration more readable and maintainable.

You can explore this page to see some examples of how you might use these conditions in the GitHub OIDC trust policy.

Using ABAC

In larger environments, Attribute-Based Access Control (ABAC) can greatly simplify access management by utilizing tags in IAM policies. Tags allow you to logically group resources, eliminating the need to explicitly list individual resources in your policies.

I also wrote about a case where tag-based control did not fit, gating Lambda’s ECR pulls from a repository policy, and what I used instead.

To learn more, check out the AWS documentation on ABAC and access control with tags.

Creating the Policies

Once the role and its trust policy are in place, the next step is to create your policies.

In my use case, as described earlier, I need the workflow to upload files to an S3 bucket and create a CloudFront invalidation to clear the cache. Here is a sample policy that allows the workflow to perform these actions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::BucketName/*",
        "arn:aws:s3:::BucketName"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "cloudfront:CreateInvalidation"
      ],
      "Resource": "arn:aws:cloudfront::AccountID:distribution/DistributionID"
    }
  ]
}

Configuring GitHub Workflows for OIDC Integration

With the AWS setup complete, it can validate OIDC tokens and issue short-lived credentials. Now it’s time to update our GitHub workflows to request these tokens and use the generated credentials within the workflow jobs.

Defining Permissions

The first step is to define the necessary permissions in the workflow’s YAML configuration file.

permissions:
  id-token: write
  contents: read

The id-token: write permission allows the workflow to request an OIDC token from the GitHub OIDC provider. The contents: read permission is needed if your workflow uses the actions/checkout action to access repository contents.

Requesting Access

With the permissions configured, the next step is to use the OIDC token within the workflow job. The aws-actions/configure-aws-credentials action simplifies this process by retrieving the JWT from GitHub’s OIDC provider and exchanging it for temporary credentials from AWS. You can find more details about this action in the official documentation.

Here is an example of how it’s implemented in the workflow file:

- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v6
  with:
    role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/github-actions-oidc-role
    aws-region: ${{ env.AWS_REGION }}

- name: Sync to S3
  run: aws s3 sync . s3://${{ env.S3_BUCKET_NAME }} --delete

- name: CloudFront Cache Invalidation
  run: aws cloudfront create-invalidation --distribution-id ${{ env.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"

Configure AWS Credentials

  • Assumes the specified IAM role (github-actions-oidc-role) using the OIDC token.
  • Retrieves short-lived credentials valid for the duration of the workflow run.

Sync to S3

  • Uses the temporary credentials to upload files to the S3 bucket.

CloudFront Cache Invalidation

  • Uses the temporary credentials to clear the CloudFront cache.

What you end up with

The pipeline now authenticates with short-lived tokens. There is no static credential left to leak.

Oh, and don’t forget to delete those static credentials from your CI/CD platform. You won’t need them anymore.

Securing cloud infrastructure like this?

We help startups and scale-ups get cloud security to a baseline they can show. Let's talk.

Book a discovery call