Blog

Securing CI/CD: Don’t Use Long-Lived API Tokens, Use OpenID Connect Instead

Written by M.Iota | Dec 4, 2024 8:58:53 PM

Have you ever felt anxious about storing your AWS or [insert other major cloud provider here] login credentials in a GitHub repo as a required step of automating deployments?

What if I told you that you could have your continuous integration environment manage your resources without preconfigured tokens?

Well, with OpenID Connect, you can! Using this technology, you no longer have to provision and store long-lived credentials to access and manage secure resources on many cloud platforms. In this article we’ll go over OpenID Connect, and how we at Amplify utilize it and how you can, too, to build trustless CI/CD pipelines.

What is OpenID Connect (OIDC)?

OpenID Connect, or OIDC, is effectively a protocol that allows one service to verify the identity of a user on another service without needing to share their credentials (e.g. username/password).

Let’s say you have an account on an app at www.alice.example (Alice’s Atelier), and you’d like to give access to it from an account on www.bob.example (Bob’s Bakery). Instead of giving Bob’s Bakery your username AND password at Alice’s Atelier, you can instead let Alice’s Atelier know your username at Bob’s Bakery, and that you’d like to give permission for Bob’s app (using your account) to access your private paintings at Alice’s Atelier. This sets up a trust relationship between your two accounts on the two different apps. Now, when you attempt to access your paintings to share with a baker for a cake design, your app and the two services will take the following steps:

  1. Bob’s app (on your phone or wherever) will ask for a token from www.bob.example. At this stage, there’s implicit trust between the app and the service as you’re logged into your account.
  2. www.bob.example will check the token request (that it really was from you, etc), and generate a token addressed to www.alice.example that’s valid for a minute or so.
  3. Bob’s app will ask www.alice.example for a token to access your paintings, sending the token we made in (2) along with it.
  4. www.alice.example will verify with www.bob.example that the token it received from (3) is legitimate and for your user. It will also check that there is a pre-defined relationship between the requesting user at Bob’s Bakery and the account at Alice’s Atelier.
  5. If it’s legit, www.alice.example will generate a temporary token with permission to view your private paintings, which is valid for, let’s say, 2 hours.
  6. Bob’s app will then use the token from (5) when communicating with www.alice.example until it no longer needs to or until the token expires.

As a result, Bob’s app is the only entity that ever obtains a short-lived sensitive credential, without Bob’s Bakery needing to ever store or even retrieve it.

In the context of continuous integration, it means allowing a CI environment, such as Github Actions, to temporarily authenticate with the API of a service like AWS using non-sensitive key identification details like the repository name and owner, as long as a trust relationship is preconfigured between the CI environment and the service. Compared to a single never-expiring API key, this improves management and security/auditing capability because a) tokens never get reused between pipelines and b) risk of unauthorized access if a token ever gets leaked is minimized, because they typically expire in an hour or two.

Enabling OIDC in your CI/CD workflows

Enabling OIDC within GitHub’s and GitLab’s CI is relatively straightforward and is done on a per-job basis in their respective configurations.

In GitHub, adding a id-token: write permission to a job will expose a few environment variables that you can use to request a token from GitHub’s OIDC provider. However, you don't have to handle this process manually—there are prebuilt Actions for all the major cloud services, like configure-aws-credentials,that use these tokens to request temporary credentials for you, as shown in the following example.

.github/workflows/example.yml


---
name: Example OIDC-enabled Workflow

on:
  push:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::000000000000:role/example-deploy
          aws-region: us-west-2

In GitLab, you specify the tokens you want to create as environment variables in your project’s .gitlab-ci.yml, and GitLab CI will automatically provision those tokens for you. You will typically have to write out the tasks to perform the OIDC token exchange and any client configuration (though this may become simplified in the future as GitLab’s CI/CD catalog matures). The following is an example showing how to request a service token from AWS and configure the CLI to use it.

.github-ci.yml


---
variables:
  ROLE_ARN: arn:aws:iam::000000000000:role/example-deploy
  AWS_REGION: us-west-2

aws-login-job:
  image:
    name: amazon/aws-cli:latest
    entrypoint: [""]
  id_tokens:
    EXAMPLE_OIDC_TOKEN:
      aud: https://gitlab.com
  before_script:
    - >
      STS=($(aws sts assume-role-with-web-identity
      --role-arn $ROLE_ARN
      --region $AWS_REGION
      --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
      --web-identity-token $EXAMPLE_OIDC_TOKEN
      --duration-seconds 3600
      --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
      --output text))
  script:
    - aws sts get-caller-identity

For the remainder of this article we’ll be focusing on using GitHub Actions with AWS, but many of the examples provided can also be replicated on GitLab.

Create a trust relationship between GitHub and AWS

Before we can work with any of the scenarios in this article, it is necessary to add the GitHub OIDC provider to your AWS account. We use Daniel Morris’s awesome terraform module for managing the provider and associated IAM roles, but you can also do this manually.

Since the provider can only be provisioned once for an account, I’d recommend using the module to initialize the provider with a permissionless role once and then reusing the module with the create_oidc_provider attribute set to false to create multiple roles for specific use cases/different repositories. But if you only need a single role for GitHub, then just using the module once is totally fine.

oidc_configuration.tf


# Sets up the GitHub OIDC provider in AWS and creates a permissionless role
module "aws_oidc_github" {
  source  = "unfunco/oidc-github/aws"
  version = "1.8.0"

  iam_role_name           = "default-oidc-role"
  attach_read_only_policy = false
  github_repositories     = []
}

# Creates a role with the AdministratorAccess policy that the repository
# "amplify-security/some-terraform-code-repo" is allowed to authenticate to.
module "aws_oidc_github_admin" {
  source     = "unfunco/oidc-github/aws"
  version    = "1.8.0"
  depends_on = [module.aws_oidc_github]

  iam_role_name           = "example-oidc-admin-role"
  create_oidc_provider    = false
  attach_admin_policy     = true
  attach_read_only_policy = false
  github_repositories = [
    "amplify-security/some-terraform-code-repo"
  ]
}

# Creates a role with an inline policy (defined later) that the repository
# "amplify-security/an-example-app" is allowed to authenticate to.
module "aws_oidc_github_deploy" {
  source     = "unfunco/oidc-github/aws"
  version    = "1.8.0"
  depends_on = [module.aws_oidc_github]

  iam_role_name = "example-oidc-ecr-deploy-role"
  create_oidc_provider = false
  attach_read_only_policy = false
  github_repositories = [
    "amplify-security/an-example-app",
  ]
  iam_role_inline_policies = {
    "EcrPushAccess" = data.aws_iam_policy_document.ecr_push.json
  }
}

# This IAM policy allows access to Amazon's Container Registry.
data "aws_iam_policy_document" "ecr_push" {
  statement {
    effect = "Allow"
    actions = [
      "ecr:BatchCheckLayerAvailability",
      "ecr:BatchGetImage",
      "ecr:CompleteLayerUpload",
      "ecr:DescribeImages",
      "ecr:DescribeRegistry",
      "ecr:InitiateLayerUpload",
      "ecr:ListImages",
      "ecr:PutImage",
      "ecr:UploadLayerPart"
    ]
    resources = [
      "arn:aws:ecr:us-west-2:000000000000:repository/an-example-app",
    ]
  }
  statement {
    effect = "Allow"
    actions = [
      "ecr:GetAuthorizationToken"
    ]
    resources = [
      "*"
    ]
  }
}

For GitLab, saidsef/gitlab-oidc/aws appears to be relatively similar to the GitHub OIDC module used above.

Case #1: Build and deploy Docker images to Amazon ECR

In an earlier article, we went over how we build Python-based Docker images for some of our software, but we need a way to deploy them to our testing and production environments. In order to do so, we need to first test and build our images in CI, then push the resulting artifacts to an Amazon Elastic Container Registry repository, e.g. 000000000000.dkr.ecr.us-west-2.amazonaws.com/an-example-app in the below example, using the IAM role we configured earlier.

.github/workflows/ci.yml


---
name: CI

on:
  push:
    branches:
  workflow_dispatch: {}

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332  # v4.1.7
      - name: Setup Python
        uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b  # v5.3.0
        with:
          python-version: "3.13"
      - name: Setup Poetry
        uses: abatilo/actions-poetry@e78f54a89cb052fff327414dd9ff010b5d2b4dbd  # v3.0.1
        with:
          poetry-version: "1.8.4"
      - name: Install Python Dependencies
        run: poetry install
      - name: Test
        run: poetry run make test
  build:
    name: Build
    needs:
      - test
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    outputs:
      tag: ${{ steps.sha.outputs.tag }}
    steps:
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81  # v5.5.1
        with:
          images: |
            000000000000.dkr.ecr.us-west-2.amazonaws.com/an-example-app
          tags: |
            type=sha
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest
      - name: Setup QEMU
        uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3  # v3.0.0
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb  # v3.3.0
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::000000000000:role/example-oidc-ecr-deploy-role
          aws-region: us-west-2
      - name: Login to Amazon ECR
        uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076  # v2.0.1
      - name: Build and Push
        uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0  # v5.3.0
        with:
          platforms: linux/amd64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
        env:
          SOURCE_DATE_EPOCH: 0

You can see that we use the aws-actions/configure-aws-credentials action shown before, and then another action, aws-actions/amazon-ecr-login, to obtain temporary credentials to use with Amazon ECR Private and configure the local Docker client to use them. In addition, we’re tagging the built images with the git commit hash, latest, and any semver version tags, should they exist. The built image is also primed to be reproducible.

Case #2: Plan and apply Terraform code changes

Infrastructure-as-Code repositories that contain Terraform/OpenTofu code often need wide-ranging permissions for an organization’s cloud accounts, so it’s important to handle any credentials involved with care. There are quite a few ways to manage Terraform in your environment, such as Digger, but to keep this example relatively simple without introducing too many moving parts, we’ll use Daniel Flook’s suite of Terraform/OpenTofu Github Actions.

At a minimum, introduce two separate workflows: one to create plans against a module in the terraform/ directory when a pull request is opened, and another one to apply that plan when a pull request is merged. The pull-requests: write permission is needed by the tofu-plan/tofu-apply Actions to leave a comment with the plan/apply results.

.github/workflows/tofu_plan.yml


---
name: Create OpenTofu plan

on:
  pull_request:

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::000000000000:role/example-oidc-admin-role
          aws-region: us-west-2
      - name: terraform plan
        uses: dflook/tofu-plan@830e0eb359a91f551ae9c06217ea855c0e87665b  # v1.44.0
        with:
          path: terraform/

.github/workflows/tofu_apply.yml


---
name: Apply OpenTofu plan

on:
  push:
    branches: [ "main" ]

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  terraform-plan:
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::000000000000:role/example-oidc-admin-role
          aws-region: us-west-2
      - name: terraform plan
        uses: dflook/tofu-apply@af009c1e6d9f60c424b495dd120f3673606b82d2  # v1.44.0
        with:
          path: terraform/

Note that these are the only files we’re adding, and we’re not storing/using any secrets here or in the project settings. Your Terraform code should then pick up the credentials from the role specified when planning/applying without any further configuration.

Authenticating Amplify workflows with Amplify's remediation platform

Amplify relies on a GitHub Action/GitLab Component to scan code and submit those results to the Amplify platform from the CI environment. Since this occurs on external infrastructure not owned nor accessible by Amplify, we have a need to verify that those submissions are from who they say they are. The easy way is to just provide users with pre-minted long-lived tokens to copy/paste into their CI workflow settings, but as we know there’s a better, less risky method using OpenID Connect.

The general implementation works as follows (curl examples are only provided for reference):

  • During user onboarding, a GitHub App creates user/organization and project mappings for user selected repositories on the platform. This step is what provides all the necessary trust conditions and creates the initial trust relationships between GitHub and Amplify for specific users and organizations. A CI workflow file with the id-token permission is installed within the selected repositories.
  • All CI runs begin with a request to GitHub’s OIDC provider for a JSON web token:
GHA_TOKEN=$(curl -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api.amplify.security")
    • This token contains claims, in the form of a JSON string, about the repository and the CI run.
  • An initial request to Amplify’s API is then made using that token to request an Amplify-issued JSON web token:
AMPLIFY_TOKEN=$(curl -H "Authorization: Bearer ${GHA_TOKEN}" https://api.amplify.security/v1.0/auth/jwt)"
    • The API uses the lestrrat-go/jwx library to then validate that the token is valid and not expired and to parse the claims into an object that can be used to perform validation on the claims themselves.
    • If valid, a new short-lived JWT is then constructed with information about the project and the CI run (e.g. job ID and pull request number) and signed with Amplify’s private key, which is then returned to the client.
  • All further API requests, such as requesting the configuration for a particular project, are then made with that token:
CONFIG="$(curl -H "Authorization: Bearer ${AMPLIFY_TOKEN}" https://api.amplify.security/v1.0/config)"
    • The token is always validated (in middleware) that it was signed by Amplify and is not expired before any platform action is taken.
    • The claims are parsed and used to associate the request with a project and/or CI run, which can be used in e.g. database queries.

If you’re developing a SaaS application that integrates with a GitHub Action, this is more or less the underlying functionality that you would need to implement in an API and in the Action to make use of OIDC tokens.

Closing thoughts

You made it to the end! Today we learned how we can safely automate continuous integration tasks, such as publishing container images, without the use of long-lived API tokens. What would you like to learn tomorrow? Get in touch with us and let us know. Follow us on LinkedIn and GitHub to stay connected and keep up with the latest developments at Amplify.