Pipeline Identities, Part 4: AWS Trusting GitHub
In this part you'll let the pipeline log in to AWS with its ID token. The call you'll cover:
- call 5, job → AWS STS: the job trades the ID token for AWS credentials.
What it adds:
- AWS is set up once to trust GitHub's tokens for your repository's
mainbranch. - No AWS secret is stored in GitHub: each run gets credentials of its own, for an hour.
- STS runs the checks prints.example runs on the OIDC page, with AWS in prints.example's place.
AWS STS and the credentials it issues
STS (Security Token Service) is the AWS service that hands out temporary credentials: it checks who the caller is, and returns keys that act as an IAM role for a limited time.
- The call this part uses is
AssumeRoleWithWebIdentity:- the job sends GitHub's ID token and the ARN of the role it asks for;
- the request is not signed with any AWS key: the job has none yet, and the ID token is the proof;
- STS checks the token's signature with GitHub's public keys, then its
audandsubagainst the role's trust policy; - STS returns credentials for that role, valid for an hour.
- The credentials are three values:
- an access key ID, naming the credentials;
- a secret access key, which signs each request to AWS and never travels;
- a session token, sent with each request, marking the credentials as temporary.
- STS has other calls you meet in AWS:
AssumeRole, for an AWS user or role becoming another role;GetCallerIdentity, which answers who signed the request.
The credentials are used by the job's steps, and by nothing else in the pipeline:
configure-aws-credentialsputs them in the job's environment, asAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEYandAWS_SESSION_TOKEN.- Every AWS command in a later step picks them up from there, and signs its requests with them:
- this part,
aws sts get-caller-identity: asks STS which role the job is; - Part 5,
aws eks update-kubeconfig: reads the cluster's address from EKS, which IAM allows by the role's permissions; - Part 5, every
kubectlrequest: carries a signed STSGetCallerIdentityrequest, which EKS sends on to STS to learn which role is calling.
- this part,
- Docker Hub and Slack never see them: those calls use tokens of their own (Parts 2 and 7), and so does the cluster pulling the image (Part 6).
- When the job ends, nobody holds them anymore, and within the hour they expire.
Check the prerequisites
Make sure you have:
- an AWS account, and the AWS CLI signed in as an admin;
terraformon your machine.
Nothing here costs money: an identity provider and a role are free.
Add the AWS steps
In
.github/workflows/pipeline.yml, add the permission Part 3 showed:1permissions: 2 contents: read 3 id-token: write # lets the job ask GitHub for an ID tokenAdd two steps after Build and push the image:
1 - name: Trade GitHub's ID token for AWS credentials 2 uses: aws-actions/configure-aws-credentials@v6 3 with: 4 role-to-assume: ${{ vars.AWS_ROLE_ARN }} 5 aws-region: ${{ vars.AWS_REGION }} 6 7 - name: Ask AWS who the job is 8 run: aws sts get-caller-identity
configure-aws-credentialsmakes the trade.- It asks GitHub for an ID token with the audience
sts.amazonaws.com, as Part 3 did by hand. - It sends the token and the role's ARN to STS, in an
AssumeRoleWithWebIdentityrequest. - It puts the credentials STS returns in the job's environment, masked in the log.
- It asks GitHub for an ID token with the audience
aws sts get-caller-identityasks AWS who the caller is. It needs no permission, which is why the role needs none yet.
Push, and read the refusal
Commit and push.
Open the run. Trade GitHub's ID token fails:
1Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers
AWS_ROLE_ARNis empty: there is no role yet, so the action has nothing to ask STS for.- AWS does not know GitHub yet either: the setup that follows creates both.
Write the Terraform files
Create a directory on your machine, pipeline-identities-aws/, outside the repository: Terraform keeps its state
there. Create three files in it.
main.tf:
1terraform {
2 required_version = ">= 1.5"
3 required_providers {
4 aws = {
5 source = "hashicorp/aws"
6 version = "~> 6.0"
7 }
8 }
9}
10
11provider "aws" {
12 region = var.region
13}
14
15# GitHub as an identity provider: AWS accepts ID tokens GitHub signed,
16# when their "aud" is sts.amazonaws.com.
17resource "aws_iam_openid_connect_provider" "github" {
18 url = "https://token.actions.githubusercontent.com"
19 client_id_list = ["sts.amazonaws.com"]
20}
21
22# The role's trust policy: who may become this role.
23# Only a token from GitHub, addressed to STS, for your repository's main branch.
24data "aws_iam_policy_document" "trust" {
25 statement {
26 actions = ["sts:AssumeRoleWithWebIdentity"]
27
28 principals {
29 type = "Federated"
30 identifiers = [aws_iam_openid_connect_provider.github.arn]
31 }
32
33 condition {
34 test = "StringEquals"
35 variable = "token.actions.githubusercontent.com:aud"
36 values = ["sts.amazonaws.com"]
37 }
38
39 condition {
40 test = "StringEquals"
41 variable = "token.actions.githubusercontent.com:sub"
42 values = ["repo:${var.github_repo}:ref:refs/heads/main"]
43 }
44 }
45}
46
47resource "aws_iam_role" "pipeline" {
48 name = "pipeline-identities"
49 assume_role_policy = data.aws_iam_policy_document.trust.json
50 max_session_duration = 3600
51}
- The identity provider names GitHub's issuer and the one audience AWS accepts.
- The trust policy is the role's rule for who may become it.
- The principal: tokens signed by that provider, with GitHub's public keys.
audmust bests.amazonaws.com, andsubmust name your repository'smainbranch.
- The role has no permissions yet: Part 5 adds them.
variables.tf:
1variable "region" {
2 type = string
3 default = "us-west-2"
4}
5
6variable "github_repo" {
7 description = "Your GitHub repository, as owner/name"
8 type = string
9}
outputs.tf:
1output "AWS_ROLE_ARN" {
2 value = aws_iam_role.pipeline.arn
3}
4
5output "AWS_REGION" {
6 value = var.region
7}
Then create values.tfvars, with your own repository:
1region = "us-west-2"
2github_repo = "your-github-user/pipeline-identities"
Apply the Terraform files
Apply them:
1$ terraform init 2$ terraform apply -var-file=values.tfvars
If apply stops with EntityAlreadyExists
An account holds one identity provider per URL, so the provider is there already. Adopt it instead of creating it, then apply again:
1$ terraform import -var-file=values.tfvars aws_iam_openid_connect_provider.github \
2> arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com
3$ terraform apply -var-file=values.tfvars
Check the result in the IAM console:
- Identity providers:
token.actions.githubusercontent.com, audiencests.amazonaws.com. - Roles → pipeline-identities → Trust relationships: the
audandsubconditions.
- Identity providers:
Add the repository's variables
In Settings → Secrets and variables → Actions → Variables, add Terraform's two outputs: AWS_ROLE_ARN and
AWS_REGION.
The role's ARN only names the role; only a token GitHub signed for your main branch can assume it. So it is a
variable, not a secret.
Run the pipeline again
Open the failed run, and Re-run all jobs. Ask AWS who the job is prints the role, with the session name the action chose:
1{
2 "UserId": "AROA…:GitHubActions",
3 "Account": "<account-id>",
4 "Arn": "arn:aws:sts::<account-id>:assumed-role/pipeline-identities/GitHubActions"
5}
Run it from another branch
The token from another branch is genuine, and STS refuses it: this time AWS itself says no.
Push the branch
tryfrom Part 3, now carrying this workflow:1$ git checkout try 2$ git merge main 3$ git pushOpen Actions → pipeline → Run workflow, and run it on
try.Read the refusal. Trade GitHub's ID token fails:
Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity.- The token's
subends inrefs/heads/try; the trust policy namesmain.
- The token's
Go back to
main, where the next parts work:1$ git checkout main