A pipeline that deploys to AWS needs credentials. The usual answer used to be an IAM user’s access key stored in the CI system’s secrets. The key never expires, anyone with access to the secrets can copy it, and rotating it is a project. OpenID Connect replaces it. The CI system signs a token that says which repository is running. AWS checks the signature and hands out credentials that expire in an hour. This is the Terraform for GitHub Actions and CircleCI, and the conditions in the trust policy that decide who gets in.
How the trust works
Three parties are involved. The CI provider issues a signed JSON Web Token for
every job. It carries an issuer (iss), an audience (aud) and a subject
(sub) that names what is running. AWS holds an OIDC identity provider per
issuer, and fetches the issuer’s public keys from it. An IAM role’s trust
policy names that provider and puts conditions on the claims. A job whose
token matches them may call sts:AssumeRoleWithWebIdentity and gets
credentials for the role, valid for an hour by default. Nothing is stored
anywhere, so the conditions are the only place access is decided. The claims a
GitHub Actions job presents:
| Claim | Value |
|---|---|
iss | https://token.actions.githubusercontent.com |
aud | sts.amazonaws.com |
sub | repo:<org>/<repo>:ref:refs/heads/main |
And a CircleCI job’s:
| Claim | Value |
|---|---|
iss | https://oidc.circleci.com/org/<org-id> |
aud | <org-id> |
sub | org/<org-id>/project/<project-id>/user/<user-id> |
GitHub’s subject carries the repository and the ref, so the trust policy below
checks sub. CircleCI’s issuer is already scoped to one organisation, so the
simplest policy checks aud. A per-project policy adds a sub condition.
What the role may do
Both roles below get the same permission, read access to one bucket, so the policy is written once and attached twice:
data "aws_iam_policy_document" "s3_read" { statement { effect = "Allow" actions = ["s3:GetObject", "s3:ListBucket"] resources = ["arn:aws:s3:::example-bucket", "arn:aws:s3:::example-bucket/*"] }}
resource "aws_iam_policy" "s3_read" { name = "s3-read-example-bucket" policy = data.aws_iam_policy_document.s3_read.json}GitHub Actions
One identity provider per AWS account represents GitHub. The thumbprint is the well-known one from GitHub’s documentation. Since mid-2023, AWS checks GitHub’s tokens against its own trusted root certificates instead of this value, as GitHub’s changelog explains, so it no longer needs rotating:
resource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"] thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]}The trust policy admits one repository, on any ref:
data "aws_iam_policy_document" "github_assume" { statement { effect = "Allow" actions = ["sts:AssumeRoleWithWebIdentity"]
principals { type = "Federated" identifiers = [aws_iam_openid_connect_provider.github.arn] }
condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:aud" values = ["sts.amazonaws.com"] }
condition { test = "StringLike" variable = "token.actions.githubusercontent.com:sub" values = ["repo:<my-org/my-repo>:*"] } }}
resource "aws_iam_role" "github_actions" { name = "github-actions-role" assume_role_policy = data.aws_iam_policy_document.github_assume.json}
resource "aws_iam_role_policy_attachment" "github_actions_s3" { role = aws_iam_role.github_actions.name policy_arn = aws_iam_policy.s3_read.arn}The workflow needs id-token: write to be issued a token at all, and the
official action does the exchange:
name: deployon: push: branches: [main]
jobs: deploy: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111111111111:role/github-actions-role aws-region: us-east-1
- run: aws sts get-caller-identity - run: aws s3 ls s3://example-bucketThe first run step is the proof: the job is the role, under a session named
by the action, and no key was involved:
aws sts get-caller-identity# {# "UserId": "AROAEXAMPLEID:GitHubActions",# "Account": "111111111111",# "Arn": "arn:aws:sts::111111111111:assumed-role/github-actions-role/GitHubActions"# }CircleCI
CircleCI issues tokens per organisation, so the provider URL and the audience both carry the organisation id. It is under Organization Settings → Overview in the CircleCI UI. The thumbprint is that of the issuer’s certificate chain, found the way AWS documents, and written as forty hexadecimal characters:
locals { circleci_org_id = "123e4567-e89b-12d3-a456-426614174000"}
resource "aws_iam_openid_connect_provider" "circleci" { url = "https://oidc.circleci.com/org/${local.circleci_org_id}" client_id_list = [local.circleci_org_id] thumbprint_list = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]}The trust policy lets in every project in the organisation. Restricting it to
one project takes a second condition: StringLike on sub with
org/<org-id>/project/<project-id>/user/*:
data "aws_iam_policy_document" "circleci_assume" { statement { effect = "Allow" actions = ["sts:AssumeRoleWithWebIdentity"]
principals { type = "Federated" identifiers = [aws_iam_openid_connect_provider.circleci.arn] }
condition { test = "StringEquals" variable = "oidc.circleci.com/org/${local.circleci_org_id}:aud" values = [local.circleci_org_id] } }}
resource "aws_iam_role" "circleci" { name = "circleci-role" assume_role_policy = data.aws_iam_policy_document.circleci_assume.json}
resource "aws_iam_role_policy_attachment" "circleci_s3" { role = aws_iam_role.circleci.name policy_arn = aws_iam_policy.s3_read.arn}The aws-cli orb does the exchange:
version: 2.1
orbs:
jobs: deploy: executor: aws-cli/default steps: - aws-cli/setup: role_arn: arn:aws:iam::111111111111:role/circleci-role region: us-east-1 - run: aws sts get-caller-identity - run: aws s3 ls s3://example-bucketThe same get-caller-identity step prints assumed-role/circleci-role/…, and
the same check applies: no key anywhere in the configuration.
Limitations
An AWS account holds one identity provider per issuer URL. A second
aws_iam_openid_connect_provider for GitHub in the same account fails with
EntityAlreadyExists. So in a repository of many Terraform stacks, the
provider belongs to one shared stack and the roles reference its ARN.
repo:<my-org/my-repo>:* lets in every branch, tag and pull request of the
repository. A role that can deploy should match the ref,
repo:my-org/my-repo:ref:refs/heads/main, or the environment,
repo:my-org/my-repo:environment:production. Those two never both match. A
job that references a GitHub environment gets the environment: form of sub
and no longer matches a ref: condition. That is the usual reason a role that
used to work starts refusing a workflow.
The CircleCI aud condition lets in every project in the organisation. It
cannot tell a production pipeline from a fork’s. Per-project access needs the
sub condition, and the project id in it.
Credentials last an hour unless the action or orb asks for more, up to the role’s maximum session duration. A job longer than that needs the longer setting, not a retry.
Where the runner has no OIDC issuer, such as an on-premises agent with no identity of its own, the static key remains the only option. Everything above is an argument for giving that runner an identity instead.
Comments