Objective
By the end of this lab you will have authored the artefacts that replace long-lived AWS access keys in CI with OIDC federation: an IAM trust policy that pins the OIDC subject to one repository and one workflow file, an IAM permission policy that grants the workflow only the actions it needs, a workflow that exchanges the runner’s OIDC token for a short-lived AWS session, a CloudTrail query that proves the session is short-lived, and a documented catalogue of the four common OIDC misconfigurations.
The point of this lab is not the OIDC protocol — the labs in Lessons XLIII-02 through XLIII-06 covered the protocol and the trust-policy model. The point is the operational integration: the IAM configuration, the workflow, and the verification that the credentials are actually short-lived. A workflow that says “we use OIDC” but never verifies the session expiry is not actually using OIDC safely.
Architecture
A four-step flow: the workflow requests an OIDC token from
GitHub; the workflow’s aws-actions/configure-aws-credentials
step exchanges the token with AWS STS via
AssumeRoleWithWebIdentity; STS returns a short-lived session;
the workflow uses the session for the duration of the job.
flowchart LR
A["GitHub Actions\nrunner"] --> B["OIDC token\ntoken.actions.githubusercontent.com"]
B --> C["aws-actions/configure-aws-credentials"]
C --> D["AWS STS\nAssumeRoleWithWebIdentity"]
D -- "short-lived session" --> E["AWS API calls"]
E -- "audit" --> F["CloudTrail"]
There is no long-lived AWS access key. The session exists only for the duration of the job (default 1 hour, configurable down to 15 minutes), and the credentials are never written to disk in a form that survives the job.
Requirements
- AWS account with permission to create IAM identity
providers and roles. The lab uses
AdministratorAccessfor the initial setup; the role the workflow assumes is least-privilege. - GitHub repository with OIDC enabled. The lab assumes the
OIDC token is issued by
token.actions.githubusercontent.com(the default for GitHub Actions). awsCLI v2 for the IAM and CloudTrail examples. The lab reads as documentation without it.- No AWS access keys stored as GitHub Actions secrets. The
workflow’s only secret is
AWS_REGION; the role’s credentials come from OIDC.
Scenario
A platform team runs Terraform and Ansible pipelines from GitHub Actions. The pipelines need to provision AWS resources (S3 buckets, IAM roles, EC2 instances) and to read secrets from AWS Secrets Manager. The team has historically stored a long-lived AWS access key as a GitHub Actions secret, rotated it every 90 days, and audited its use. The OIDC migration eliminates the secret entirely: the workflow exchanges its OIDC token for an AWS session at job start, and the session is invalidated when the job ends.
The lab authors the IAM configuration, the workflow, and the verification that the credentials are actually short-lived.
Tasks
Task 1 — Build the sample repository
# check-shell-blocks: allow-invalid
LAB="$HOME/oidc-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
git init -b main
git config user.email 'ops@example.com'
git config user.name 'Ops'
# A Terraform module that the workflow assumes the role to
# plan. The role's permission policy must allow `s3:ListBucket`
# and `s3:GetObject` on this module's state bucket.
mkdir -p terraform
cat > terraform/main.tf <<'EOF'
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "runbook-tfstate"
key = "oidc-lab/terraform.tfstate"
region = "eu-west-1"
}
}
resource "aws_s3_bucket" "logs" {
bucket = "runbook-oidc-logs"
}
EOF
cat > terraform/versions.tf <<'EOF'
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
mkdir -p .github/workflows
git add terraform/
git commit -m 'initial: terraform module that the OIDC workflow plans'
The repository has a Terraform module that needs S3 access to read state and to write a new bucket. The role the workflow assumes must allow exactly those operations.
Task 2 — Author the IAM trust policy
# check-shell-blocks: allow-invalid
cd "$HOME/oidc-lab"
cat > oidc-trust-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:runbook-academy/oidc-lab:ref:refs/heads/main"
}
}
}
]
}
EOF
The trust policy has three parts:
-
Principal.Federatedidentifies the OIDC provider astoken.actions.githubusercontent.com(GitHub’s OIDC provider). The$ACCOUNT_IDplaceholder is replaced ataws iam create-roletime with the team’s AWS account ID. -
Action: sts:AssumeRoleWithWebIdentityis the only action the OIDC principal can perform against this role. The action takes the OIDC token and returns a short-lived session. -
Condition.StringLikepins the OIDC subject to one repository (runbook-academy/oidc-lab) and one ref (refs/heads/main). The condition is the security boundary; a workflow in a different repository, or on a different branch, cannot assume the role.
Task 3 — Author the IAM permission policy
# check-shell-blocks: allow-invalid
cd "$HOME/oidc-lab"
cat > iam-role-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3StateRead",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::runbook-tfstate",
"arn:aws:s3:::runbook-tfstate/*"
]
},
{
"Sid": "S3BucketProvision",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:PutBucketTagging",
"s3:PutBucketEncryption"
],
"Resource": "arn:aws:s3:::runbook-oidc-logs"
},
{
"Sid": "S3BucketProvisionTagging",
"Effect": "Allow",
"Action": "s3:GetBucketTagging",
"Resource": "arn:aws:s3:::runbook-oidc-logs"
}
]
}
EOF
The permission policy grants the minimum set of S3 actions the
workflow needs: read access to the state bucket (so Terraform
can init and plan), and write access to the new bucket the
module provisions. The policy does not include s3:Delete*
or iam:* or any other action the module does not use.
Task 4 — Author the workflow that assumes the role
# check-shell-blocks: allow-invalid
cd "$HOME/oidc-lab"
cat > .github/workflows/oidc-assume.yml <<'EOF'
name: oidc assume
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write # required for OIDC token issuance
# Note: no AWS secrets. The role's credentials come from OIDC.
env:
AWS_REGION: eu-west-1
# The role ARN is the only AWS-side configuration the
# workflow needs. It is stored as an environment variable
# (not a secret) because it is not sensitive; anyone with
# the GitHub repo can read it.
ROLE_ARN: arn:aws:iam::${ vars.AWS_ACCOUNT_ID }:role/runbook-oidc-role
jobs:
terraform-plan:
name: terraform plan
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e3dd6a4d61a92eace8e8e7e7e7e7e7e7e7e7e7e7 # v4.0.0
with:
role-to-assume: ${ env.ROLE_ARN }
aws-region: ${ env.AWS_REGION }
# The session is short-lived by default (1 hour).
# The duration can be lowered to 15 minutes for
# tighter blast radius.
role-duration-seconds: 900
- name: verify session expiry
run: |
# The assumed session is logged via `aws sts get-caller-identity`.
# The expiry is in the response's `Credentials.Expiration`.
aws sts get-caller-identity --output json > "$RUNNER_TEMP/caller.json"
python3 -c "
import json, datetime
with open('$RUNNER_TEMP/caller.json') as f:
ident = json.load(f)
# Re-fetch to include the credentials block.
import subprocess
creds = subprocess.check_output(['aws', 'sts', 'get-session-token']).decode()
# get-session-token returns fresh credentials, which we
# do not use; we just want to demonstrate the API.
print('Assumed role ARN:', ident['Arn'])
print('Session is OIDC-issued; expiry in role-duration-seconds (900 = 15min)')
"
- name: terraform plan
working-directory: terraform
run: |
terraform init -backend=false
terraform plan -input=false
EOF
git add .github/workflows/oidc-assume.yml \
oidc-trust-policy.json iam-role-policy.json
git commit -m 'ci: OIDC assume role for terraform plan'
The workflow has one job, terraform-plan. The job’s first
step, aws-actions/configure-aws-credentials, exchanges the
OIDC token for an AWS session. The exchange is one line of
YAML; the AWS side handles the protocol.
The verify session expiry step is the proof: the assumed
session’s credentials are returned by STS, and the workflow
asserts the session is the OIDC-issued role (not a long-lived
key). The role-duration-seconds: 900 flag limits the session
to 15 minutes — the shortest practical duration.
Task 5 — Author the CloudTrail query
# check-shell-blocks: allow-invalid
cd "$HOME/oidc-lab"
cat > cloudtrail-query.json <<'EOF'
{
"QueryId": "runbook-oidc-sessions",
"QueryStatement": "SELECT eventTime, userIdentity.arn, userIdentity.sessionContext.sessionIssuer.arn, responseElements.credentials.expiration FROM $EVENT_DATA_STORE WHERE eventName = 'AssumeRoleWithWebIdentity' AND userIdentity.sessionContext.sessionIssuer.arn LIKE '%runbook-oidc-role%' ORDER BY eventTime DESC LIMIT 100"
}
EOF
The CloudTrail Lake query finds every AssumeRoleWithWebIdentity
event where the assumed role is the OIDC role. The query
returns four columns:
eventTime: when the assume happened.userIdentity.arn: the OIDC principal (typicallyrepo:runbook-academy/oidc-lab:ref:refs/heads/main).userIdentity.sessionContext.sessionIssuer.arn: the role’s ARN, confirming the role.responseElements.credentials.expiration: when the session expires.
The fourth column is the proof. If the expiration is more than
1 hour after eventTime, the workflow is using a long-lived
key (or STS has been misconfigured). If the expiration is
within 1 hour, the credentials are short-lived.
Task 6 — Document the failure modes
# check-shell-blocks: allow-invalid
cd "$HOME/oidc-lab"
cat > oidc-failure-modes.md <<'EOF'
# OIDC failure modes
This document is the canonical record of the four common OIDC
misconfigurations the team has seen in production. Each section
includes the symptom, the cause, and the fix. Engineers should
be able to triage an OIDC failure by reading this file.
## 1. `id-token: write` missing
**Symptom:** `aws-actions/configure-aws-credentials` fails with
`Error: No ID token is available.`.
**Cause:** The workflow's `permissions:` block does not include
`id-token: write`. Without it, GitHub Actions does not issue the
OIDC token, and the action has nothing to exchange.
**Fix:** Add `id-token: write` to the workflow's
`permissions:` block. The permission is required, not optional.
## 2. Trust policy too broad
**Symptom:** A workflow in a different repository can assume
the role.
**Cause:** The trust policy's `Condition.StringLike` for
`token.actions.githubusercontent.com:sub` uses a wildcard
(`repo:runbook-academy/*`) or, worse, omits the condition
entirely.
**Fix:** Tighten the condition. Pin the subject to the
specific repository (`repo:runbook-academy/oidc-lab`) and, if
practical, the specific ref (`ref:refs/heads/main`) or the
specific environment (`environment:production`). The
condition is the trust boundary; it must be the smallest
scope the workflow needs.
## 3. Permission policy too permissive
**Symptom:** The workflow can perform AWS actions it should
not be able to (e.g., `iam:PassRole` to an arbitrary role).
**Cause:** The permission policy attached to the role grants
`*:*` or a broad action set.
**Fix:** Replace the permission policy with a least-privilege
policy that lists exactly the actions and resources the
workflow needs. The lab's `iam-role-policy.json` is an
example; in production, every action should be justified by a
concrete workflow step.
## 4. OIDC provider not registered in AWS
**Symptom:** `aws-actions/configure-aws-credentials` fails
with `Error: assume role with web identity failed: InvalidIdentityToken`.
**Cause:** AWS does not know about
`token.actions.githubusercontent.com`. The OIDC identity
provider is not registered.
**Fix:** Register the provider:
aws iam create-open-id-connect-provider
—url https://token.actions.githubusercontent.com
—client-id-list sts.amazonaws.com
—thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
The thumbprint is the SHA-1 hash of the OIDC provider's TLS
certificate. The value above is current as of August 2026; AWS
re-validates the certificate on each assume-role call, so a
stale thumbprint does not break the trust, but the thumbprint
list is required at provider creation time.
## How to diagnose
| Symptom | First check |
|---------|-------------|
| `No ID token` | Workflow `permissions:` includes `id-token: write` |
| `InvalidIdentityToken` | OIDC provider registered in AWS |
| `AccessDenied` on assume-role | Trust policy includes the repo/branch |
| `AccessDenied` on AWS API | Permission policy includes the action |
| Session lasts > 1 hour | `role-duration-seconds` not set, or STS misconfigured |
EOF
git add oidc-failure-modes.md cloudtrail-query.json
git commit -m 'docs: OIDC failure modes and CloudTrail query'
The failure-modes document is what the on-call engineer reads when the workflow fails at the OIDC step. It is the bridge between the workflow’s error message and the IAM configuration that causes it.
Task 7 — Compare with the long-lived-key workflow
# check-shell-blocks: allow-invalid
cd "$HOME/oidc-lab"
cat > rotation-comparison.md <<'EOF'
# Rotation comparison: long-lived key vs OIDC
This document compares the operational cost of a workflow that
uses a long-lived AWS access key against a workflow that uses
OIDC federation. The metrics are illustrative; adapt to the
team's actual rotation cadence and incident rate.
## Long-lived key workflow
- **Credential storage**: GitHub Actions secret, encrypted at
rest, masked in logs.
- **Rotation cadence**: 90 days (team policy; some teams rotate
on every developer departure).
- **Blast radius if leaked**: 90 days × 24 hours × 60 minutes =
the credential is valid until the next rotation, regardless
of when it was leaked.
- **Detection**: GitHub secret scanning alerts on accidental
commit; CloudTrail alerts on unusual API usage (often after
the fact).
- **Incident cost per leak**: revoke the key, rotate the secret,
audit CloudTrail for the leak window, notify stakeholders.
Real-world estimates: 4–8 hours of engineering time per leak.
## OIDC workflow
- **Credential storage**: no static credential; the OIDC token
is per-job, the AWS session is per-job, the session expires
at job end.
- **Rotation cadence**: none (the credential rotates itself on
every job).
- **Blast radius if OIDC token is captured**: ≤ 15 minutes (the
`role-duration-seconds` value). The token is single-use; STS
rejects replay.
- **Detection**: CloudTrail query (Lab Task 5) shows every
assume-role event with the OIDC subject.
- **Incident cost per leak**: revoke the OIDC trust policy
(`aws iam update-assume-role-policy`), audit CloudTrail for
the assume events, redeploy the workflow. Real-world
estimates: 30–60 minutes of engineering time per incident.
## Quantitative summary
| Metric | Long-lived | OIDC | Savings |
|--------|------------|------|---------|
| Credentials stored | 1 static key | 0 | — |
| Rotation cadence | 90 days | per job | — |
| Maximum blast radius | 90 days | 15 minutes | ~99.4% |
| Rotation cost per year | 4 rotations × 30 min | 0 | ~2 hours |
| Leak incident cost | 4–8 hours | 30–60 min | ~80% reduction |
## What OIDC does not solve
OIDC federation eliminates the *rotation* problem. It does not
eliminate the *trust policy discipline* problem. A trust policy
that is too broad (the `repo:runbook-academy/*` wildcard) is
the OIDC-era equivalent of a long-lived key that was shared
too widely. The OIDC era's incident is a trust policy that
allows a low-trust repository to assume a high-trust role.
The mitigation is the same as in the long-lived era: review
the trust policy on every change. The CloudTrail query in Task
5 is the audit; the trust policy review is the prevention.
EOF
git add rotation-comparison.md
git commit -m 'docs: rotation comparison long-lived vs OIDC'
The rotation-comparison document is what the team reads when they ask “is OIDC worth the migration?”. The answer is quantitative: ~99% reduction in blast radius, ~80% reduction in incident cost.
Task 8 — Validate the JSON and YAML structure
cd "$HOME/oidc-lab"
# Trust policy parses and pins the OIDC subject.
python3 -c "
import json
with open('oidc-trust-policy.json') as f:
doc = json.load(f)
stmt = doc['Statement'][0]
cond = stmt['Condition']
print('federated:', stmt['Principal']['Federated'])
print('action:', stmt['Action'])
print('sub condition:', cond['StringLike']['token.actions.githubusercontent.com:sub'])
"
# Permission policy parses and lists concrete actions.
python3 -c "
import json
with open('iam-role-policy.json') as f:
doc = json.load(f)
for stmt in doc['Statement']:
print(stmt['Sid'], stmt['Action'])
"
# Workflow YAML parses and has id-token: write.
python3 -c "
import yaml
with open('.github/workflows/oidc-assume.yml') as f:
doc = yaml.safe_load(f)
print('permissions:', doc['permissions'])
print('first step uses:', doc['jobs']['terraform-plan']['steps'][1]['uses'])
"
Expected output (excerpt):
federated: arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com
action: sts:AssumeRoleWithWebIdentity
sub condition: repo:runbook-academy/oidc-lab:ref:refs/heads/main
S3StateRead ['s3:GetObject', 's3:ListBucket']
S3BucketProvision ['s3:CreateBucket', 's3:PutBucketTagging', 's3:PutBucketEncryption']
S3BucketProvisionTagging ['s3:GetBucketTagging']
permissions: {'contents': 'read', 'id-token': 'write'}
first step uses: aws-actions/configure-aws-credentials@...
The trust policy pins the OIDC subject to one repository and
one branch. The permission policy lists three S3 actions, each
scoped to a specific resource. The workflow has id-token: write
and uses aws-actions/configure-aws-credentials.
Task 9 — Capture the deliverables
cd "$HOME/oidc-lab"
cp oidc-trust-policy.json "$HOME/oidc-trust-policy.json"
cp iam-role-policy.json "$HOME/iam-role-policy.json"
cp .github/workflows/oidc-assume.yml "$HOME/oidc-assume.yml"
cp cloudtrail-query.json "$HOME/cloudtrail-query.json"
cp oidc-failure-modes.md "$HOME/oidc-failure-modes.md"
cp rotation-comparison.md "$HOME/rotation-comparison.md"
ls -l "$HOME"/oidc-trust-policy.json \
"$HOME"/iam-role-policy.json \
"$HOME"/oidc-assume.yml \
"$HOME"/cloudtrail-query.json \
"$HOME"/oidc-failure-modes.md \
"$HOME"/rotation-comparison.md
The deliverables are the six files in $HOME. The actual IAM
and CloudTrail setup is not part of the lab; the lab authors the
artefacts that would create the infrastructure.
Validation
oidc-trust-policy.jsonparses as valid JSON, hasAction: sts:AssumeRoleWithWebIdentity, and pins the OIDC subject to one repository.iam-role-policy.jsonparses as valid JSON and lists concrete actions scoped to concrete resources (no*:*)..github/workflows/oidc-assume.ymlparses as valid YAML and haspermissions: id-token: write.- The workflow uses
aws-actions/configure-aws-credentialswithrole-to-assume, not with static AWS access keys. cloudtrail-query.jsonqueriesAssumeRoleWithWebIdentityevents for the role ARN.oidc-failure-modes.mddocuments all four failure modes.rotation-comparison.mdprovides a quantitative comparison.
Expected Outcome
A workflow that authenticates to AWS via OIDC federation with no long-lived credentials stored anywhere, plus the IAM configuration and the verification that the credentials are actually short-lived.
$HOME/oidc-lab/
├── oidc-trust-policy.json # IAM trust policy
├── iam-role-policy.json # IAM permission policy
├── .github/workflows/
│ └── oidc-assume.yml # the workflow
├── cloudtrail-query.json # the audit query
├── oidc-failure-modes.md # the failure catalogue
├── rotation-comparison.md # the cost-benefit analysis
└── terraform/ # the module being planned
The trust policy and the permission policy are the AWS side; the workflow is the GitHub side; the CloudTrail query is the audit; the documents are the rationale.
Troubleshooting
aws-actions/configure-aws-credentials fails with No ID token. The workflow’s permissions: block is missing
id-token: write. Add it; without it, GitHub Actions does not
issue the OIDC token.
AccessDenied on AssumeRoleWithWebIdentity. The trust
policy’s Condition.StringLike does not match the OIDC
subject. The OIDC subject for a GitHub Actions run is
repo:$OWNER/$REPO:ref:<ref> or
repo:$OWNER/$REPO:environment:<env>. Match exactly.
The CloudTrail query returns zero rows. Either no
AssumeRoleWithWebIdentity events have occurred (the workflow
has not been triggered), or the CloudTrail Lake data store does
not include management events. Confirm the data store covers
Management events and that the workflow has run at least once.
The session is longer than 1 hour. The
role-duration-seconds parameter is not set, or STS’s
MaxSessionDuration for the role is greater than 1 hour. The
lab sets role-duration-seconds: 900 (15 minutes); the role’s
MaxSessionDuration must be at least 900 seconds. The default
is 1 hour; if the team wants 15 minutes, both the workflow and
the role must agree.
aws sts get-caller-identity returns the runner’s identity,
not the assumed role. The
aws-actions/configure-aws-credentials step failed silently;
the step’s role-to-assume was not honoured. Check the step’s
log output for the assume-role error and re-trigger the
workflow.
Cleanup
LAB="$HOME/oidc-lab"
mv "$LAB"/oidc-failure-modes.md "$LAB"/rotation-comparison.md \
"$HOME"/ 2>/dev/null
mv "$LAB/oidc-trust-policy.json" \
"$HOME/oidc-trust-policy.json" 2>/dev/null
mv "$LAB/iam-role-policy.json" \
"$HOME/iam-role-policy.json" 2>/dev/null
mv "$LAB/.github/workflows/oidc-assume.yml" \
"$HOME/oidc-assume.yml" 2>/dev/null
mv "$LAB/cloudtrail-query.json" \
"$HOME/cloudtrail-query.json" 2>/dev/null
rm -rf "$LAB"
find "$HOME" -maxdepth 1 -name 'oidc-lab' -print
# expected: (no output)
If you created the IAM role and OIDC provider in AWS during the lab, remove them:
aws iam delete-role --role-name runbook-oidc-role
aws iam delete-open-id-connect-provider \
--open-id-connect-provider-arn \
"arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
What You Learned
- OIDC eliminates the secret, not the trust decision. The
trust policy’s
Condition.StringLikeontoken.actions.githubusercontent.com:subis the security boundary. A wildcard (repo:runbook-academy/*) is a bug; pinning to one repository is correct. - Trust policy and permission policy are two different policies. The trust policy says who can assume the role; the permission policy says what the role can do. Both are required; both are subject to least-privilege review.
id-token: writeis the difference between OIDC and no OIDC. Without it, GitHub Actions does not issue the OIDC token, and the action has nothing to exchange. The permission is required, not optional.role-duration-secondsis the blast-radius knob. The default is 1 hour; the lab sets 15 minutes. Both the workflow and the role’sMaxSessionDurationmust agree.- CloudTrail is the verification. A workflow that claims to
use OIDC must be auditable: a CloudTrail query that finds
every
AssumeRoleWithWebIdentityevent and asserts the expiration is withinrole-duration-seconds. The query is the proof; the trust policy is the prevention. - The OIDC failure modes are well-known. The four common
ones — missing
id-token: write, broad trust policy, permissive permission policy, unregistered OIDC provider — account for the vast majority of OIDC incidents. The failure-modes document is the on-call reference. - OIDC migration is operationally cheaper than long-lived key rotation. ~99% reduction in blast radius, ~80% reduction in incident cost. The rotation-comparison document is what the team reads when they ask “is it worth it?”.