Scenario
Section titled “Scenario”A deployment workflow authenticates to AWS with AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
stored as repository secrets. The key was created two years ago. It has never been rotated. It
works from any machine on earth, forever, and every workflow in the repository — including one
added tomorrow — can read it.
The team knows OIDC is the answer and has been putting it off because “the trust policy is confusing”. It is confusing in exactly one place, and this lab is mostly about that place.
Objective
Section titled “Objective”Replace the stored key with a per-run OIDC token; write a trust policy whose conditions restrict the role to this repository, this branch; and prove the conditions work by watching a second workflow fail to assume the role.
Prerequisites
Section titled “Prerequisites”- OIDC in GitHub Actions — the trust architecture and what the token says
- AWS OIDC — provider-specific setup, referenced from step 3
- Least-privilege permissions
Starting state
Section titled “Starting state”The workflow to migrate. Commit it to a scratch repository:
mkdir -p /tmp/lab-oidc/.github/workflows && cd /tmp/lab-oidcgit init -q -b main .git config user.email "lab@example.com"git config user.name "Lab User"
cat > .github/workflows/deploy.yml <<'EOF'name: Deploy
on: push: branches: [main]
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: eu-west-1
- name: Show who we are run: aws sts get-caller-identityEOF
git add . && git commit -q -m "Deploy workflow with stored credentials"-
Audit the workflow. Before changing anything, list what is wrong with it. There are at least four things, and only one of them is the stored key.
-
Create the OIDC identity provider and a role in AWS. Follow the provider steps in AWS OIDC: add
token.actions.githubusercontent.comas an identity provider with audiencests.amazonaws.com, then create a role with this trust policy — deliberately too permissive, to be tightened in step 5:{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"},"Action": "sts:AssumeRoleWithWebIdentity","Condition": {"StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"}}}]}Replace the account ID. Attach a read-only policy (for example
ReadOnlyAccess) — the lab only callssts:GetCallerIdentity. -
Rewrite the workflow. Remove both secrets; request an ID token; assume the role:
Terminal window cat > .github/workflows/deploy.yml <<'EOF'name: Deployon:push:branches: [main]permissions:contents: readid-token: write # mint the OIDC token for this jobjobs:deploy:runs-on: ubuntu-latestenvironment: productionsteps:- uses: actions/checkout@v7- name: Configure AWS credentials via OIDCuses: aws-actions/configure-aws-credentials@v6with:role-to-assume: arn:aws:iam::123456789012:role/github-deployaws-region: eu-west-1- name: Show who we arerun: aws sts get-caller-identityEOFgit add . && git commit -q -m "Migrate deploy workflow to OIDC"Push. The run should succeed and
get-caller-identityshould show the assumed role’s ARN. Delete the two secrets from the repository — they are unused now. -
Prove the trust policy is too broad. Create a second scratch repository with the same workflow, and push. It also succeeds. Any repository on GitHub can assume this role, because the only condition is the audience.
-
Tighten the trust policy. Add a subject condition so only
mainin your repository qualifies:"Condition": {"StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com","token.actions.githubusercontent.com:sub": "repo:YOUR-ORG/YOUR-REPO:ref:refs/heads/main"}}Re-run both repositories’ workflows. The first succeeds; the second fails at “Configure AWS credentials” with an
AccessDeniedfrom STS. -
Try one more escape. In the first repository, push the workflow to a branch called
feature. It fails too — the subject saysref:refs/heads/feature, which does not match. Now change the workflow’senvironment:tostagingand push tomain. Does it succeed?
Validation
Section titled “Validation”After step 5: the original repository’s main run passes; the second repository’s run and the
feature-branch run both fail at credential configuration. aws sts get-caller-identity in the
passing run shows assumed-role/github-deploy/... — a session, not a user.
Step 1. Beyond the stored key: no permissions: block (so the default, possibly permissive,
token applies); no environment: (so no required reviewers or environment-scoped protection);
the secrets are repository-wide rather than environment-scoped; and the workflow runs on every
push to main with no concurrency guard.
Step 4. The token’s issuer is the same for every repository on GitHub. Trusting the issuer and the audience alone means trusting all of GitHub.
Step 5. The sub claim’s shape depends on the run: repo:ORG/REPO:ref:refs/heads/BRANCH for
a branch push, repo:ORG/REPO:environment:NAME when the job specifies an environment,
repo:ORG/REPO:pull_request for a pull request. Match the shape your job produces.
Step 6. With environment: production set in the job, the sub claim becomes
repo:ORG/REPO:environment:production — and the trust policy from step 5 matches on
ref:refs/heads/main. Depending on which you matched, one of these two runs fails. Pick one
shape and condition on it deliberately.
Solution
Section titled “Solution”Step 1 — the audit:
| Problem | Consequence |
|---|---|
| Long-lived key in a secret | Valid anywhere, until rotated; readable by every workflow in the repo |
No permissions: | GITHUB_TOKEN gets the repository default, which may be read-write |
No environment: | No required reviewers, no environment-scoped secrets or rules |
Runs on every push to main with no concurrency | Two deploys can overlap |
Step 5 — the tightened trust policy, complete:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:YOUR-ORG/YOUR-REPO:environment:production" } } } ]}This version conditions on the environment claim, which is what the job in step 3 produces
because it declares environment: production. That is the stronger choice: it ties the role to a
GitHub environment that can carry required reviewers, rather than to a branch name anyone with
write access can push to.
Explanation
Section titled “Explanation”OIDC replaces a secret with a proof. The workflow asks GitHub for a signed token that says “I am repository X, on ref Y, in environment Z, running workflow W”. The cloud provider verifies GitHub’s signature and checks the claims against the trust policy. Nothing is stored; the token lives for the length of the job.
The trust policy is the whole security boundary. The identity provider is shared by every
GitHub repository. Without a sub condition, the role is open to all of them — step 4 proves it.
The confusing part is the shape of sub, which changes with how the job was triggered and
whether it names an environment. Inspect a real token once (the OIDC lesson
shows how) and copy the shape you see.
id-token: write is the only new permission. It lets the job request the token. It does
not grant anything in AWS; the trust policy does that.
What OIDC does not do. It does not shrink the role’s permissions. A per-run token that assumes an administrator role is still an administrator for the length of the run. Least privilege on the role is a separate, still-necessary job.
Troubleshooting
Section titled “Troubleshooting”Not authorized to perform sts:AssumeRoleWithWebIdentity on the run that should pass. The
sub shape does not match. Add a step that prints the token’s claims — the OIDC lesson has the
curl — and compare against the condition character by character.
Error: Credentials could not be loaded and no STS error. id-token: write is missing from
permissions:, so no token was minted to present.
Both repositories still succeed after step 5. The trust policy edit did not save, or the condition key has a typo. Condition keys are case-sensitive and must include the full issuer hostname.
Clean up
Section titled “Clean up”Delete the IAM role and the identity provider in AWS, and both scratch repositories on GitHub. Then:
cd /tmp && rm -rf lab-oidcRelated lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Terraform pull request workflow — plan on the pull request, and catch the one-line change that destroys a resource.