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 main branch.
  • 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 aud and sub against 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-credentials puts them in the job's environment, as AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_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 kubectl request: carries a signed STS GetCallerIdentity request, which EKS sends on to STS to learn which role is calling.
  • 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;
  • terraform on your machine.

Nothing here costs money: an identity provider and a role are free.

Add the AWS steps

  1. 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 token
    
  2. Add 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-credentials makes 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 AssumeRoleWithWebIdentity request.
    • It puts the credentials STS returns in the job's environment, masked in the log.
  • aws sts get-caller-identity asks AWS who the caller is. It needs no permission, which is why the role needs none yet.

Push, and read the refusal

  1. Commit and push.

  2. 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_ARN is 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.
    • aud must be sts.amazonaws.com, and sub must name your repository's main branch.
  • 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

  1. 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
  1. Check the result in the IAM console:

    • Identity providers: token.actions.githubusercontent.com, audience sts.amazonaws.com.
    • Roles → pipeline-identities → Trust relationships: the aud and sub conditions.

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.

  1. Push the branch try from Part 3, now carrying this workflow:

    1$ git checkout try
    2$ git merge main
    3$ git push
    
  2. Open Actions → pipeline → Run workflow, and run it on try.

  3. Read the refusal. Trade GitHub's ID token fails: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity.

    • The token's sub ends in refs/heads/try; the trust policy names main.
  4. Go back to main, where the next parts work:

    1$ git checkout main