A pipeline that deploys to AWS needs credentials, and 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, carrying an issuer (iss), an audience (aud) and a subject
(sub) that names what is running. AWS holds an OIDC identity provider per
issuer, from which it fetches the issuer’s public keys. An IAM role’s trust
policy names that provider and puts conditions on the claims; a job that
presents a token matching 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 conditions on sub. CircleCI’s issuer is already scoped to one
organisation, so the simplest policy conditions on aud, and a per-project
one 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 validates GitHub’s tokens against its own trusted root certificates rather than 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, found under Organization Settings → Overview in the CircleCI UI. The thumbprint is that of the issuer’s certificate chain, obtained the way AWS documents, 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 admits every project in the organisation. Restricting it to
one project is 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>:* admits 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 are exclusive: a job
that references a GitHub environment gets the environment: form of sub
and no longer matches a ref: condition, which is the usual reason a
previously working role starts refusing a workflow.
The CircleCI aud condition admits 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, an on-premises agent with no identity of its own, the static key remains the only option, and everything above is an argument for giving that runner an identity instead.
Comments