Securing CI/CD: Don’t Use Long-Lived API Tokens, Use OpenID Connect Instead
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:
- 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. www.bob.example
will check the token request (that it really was from you, etc), and generate a token addressed towww.alice.example
that’s valid for a minute or so.- 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. www.alice.example
will verify withwww.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.- 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. - 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.