Skip to content

Lab: Migrate a Workflow from Stored Secrets to OIDC

Lesson 2 of 2Advanced5 min readHands-On Git & GitHub Labs · Security Labs
Time35 minutes
LevelAdvanced
You needA GitHub repository you can push to, and an AWS account where you may create one IAM role (the pattern is identical for Azure and Google Cloud)

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.

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.

The workflow to migrate. Commit it to a scratch repository:

Terminal window
mkdir -p /tmp/lab-oidc/.github/workflows && cd /tmp/lab-oidc
git 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-identity
EOF
git add . && git commit -q -m "Deploy workflow with stored credentials"
  1. 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.

  2. Create the OIDC identity provider and a role in AWS. Follow the provider steps in AWS OIDC: add token.actions.githubusercontent.com as an identity provider with audience sts.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 calls sts:GetCallerIdentity.

  3. Rewrite the workflow. Remove both secrets; request an ID token; assume the role:

    Terminal window
    cat > .github/workflows/deploy.yml <<'EOF'
    name: Deploy
    on:
    push:
    branches: [main]
    permissions:
    contents: read
    id-token: write # mint the OIDC token for this job
    jobs:
    deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
    - uses: actions/checkout@v7
    - name: Configure AWS credentials via OIDC
    uses: aws-actions/configure-aws-credentials@v6
    with:
    role-to-assume: arn:aws:iam::123456789012:role/github-deploy
    aws-region: eu-west-1
    - name: Show who we are
    run: aws sts get-caller-identity
    EOF
    git add . && git commit -q -m "Migrate deploy workflow to OIDC"

    Push. The run should succeed and get-caller-identity should show the assumed role’s ARN. Delete the two secrets from the repository — they are unused now.

  4. 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.

  5. Tighten the trust policy. Add a subject condition so only main in 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 AccessDenied from STS.

  6. Try one more escape. In the first repository, push the workflow to a branch called feature. It fails too — the subject says ref:refs/heads/feature, which does not match. Now change the workflow’s environment: to staging and push to main. Does it succeed?

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.

Step 1 — the audit:

ProblemConsequence
Long-lived key in a secretValid 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 concurrencyTwo 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.

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.

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.

Delete the IAM role and the identity provider in AWS, and both scratch repositories on GitHub. Then:

Terminal window
cd /tmp && rm -rf lab-oidc

Terraform pull request workflow — plan on the pull request, and catch the one-line change that destroys a resource.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.