DEV Community

Indra Gusti Prasetya
Indra Gusti Prasetya

Posted on • Originally published at indragustiprasetya.com

GitHub Actions OIDC to AWS: 10 Tips to Kill Static Keys

An AWS_SECRET_ACCESS_KEY living in GitHub Actions secrets is a long-lived credential that never rotates, gets exfiltrated by one poisoned dependency, and shows up in nobody's audit trail. OIDC federation deletes it. GitHub mints a short-lived JWT per run, AWS STS trades that token for temporary credentials, and there is no static key left to steal. Simple thesis, sharp edges. One of those edges changes on July 15, 2026, and a trust policy that looks correct today will quietly stop matching. These tips are for the engineer wiring this into a real repo who wants it scoped right the first time, not lifted from a tutorial that grants repo:org/* and calls the job done.

The tips

  1. Register the OIDC provider once per account, and drop the thumbprint. You add token.actions.githubusercontent.com as an IAM OIDC provider once in each AWS account. Per the AWS IAM docs, the thumbprint is now optional: IAM validates GitHub's JWKS endpoint against its own library of trusted root CAs and only falls back to a configured thumbprint when the cert is not signed by a trusted CA. So delete that SHA1 string you copied from a 2022 blog post. It does nothing now.
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com
Enter fullscreen mode Exit fullscreen mode
  1. The first thing that breaks is a missing id-token: write. Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity is the single most-reported failure on the configure-aws-credentials repo (issues #318, #961, #1137). Half the time the trust policy is fine and GitHub never minted a token at all, because the job lacked permission to request one. Set it at job scope, not just as a workflow default that a matrix job can shadow.
permissions:
  id-token: write   # required to mint the OIDC token
  contents: read
Enter fullscreen mode Exit fullscreen mode
  1. Pin the audience to sts.amazonaws.com. The official action requests aud: sts.amazonaws.com, so your trust policy asserts it with StringEquals. GitHub's own guidance is blunt about the stakes: you must define at least one condition, or any repository on GitHub can request a token that assumes your role. The audience is not that protective condition. It is table stakes.

  2. Pin the sub claim to a branch or environment, never a wildcard. StringLike with repo:octo-org/octo-repo:* hands the role to every branch, every PR, and every fork's merge ref. For anything that touches production, use StringEquals on an exact sub. The formats you actually need: ref:refs/heads/main for a branch, ref:refs/tags/v1.2.3 for a tag, environment:prod for a deployment environment, pull_request for PR runs.

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:ref:refs/heads/main"
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Prepare for the July 15, 2026 immutable subject-claim change now. Per GitHub's April 23, 2026 changelog, the default sub claim is gaining immutable owner and repo IDs. Any repository created, renamed, or transferred after July 15, 2026 will mint a sub shaped like repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. A policy pinned to the old name-only string stops matching the moment someone renames the repo, with no error until the deploy fails. If your sub conditions are exact-match on names, audit them before the deadline. This is exactly the gotcha a model trained before April 2026 gets wrong.

  2. Pin repository_id, not the name, to kill the rename-squat confused deputy. The reason behind the immutable change is real, and worth understanding rather than just working around. Delete or rename a repo, and someone can register a new repo that reclaims the freed name and mint a token whose name-based sub still satisfies your trust policy. GitHub exposes a stable numeric repository_id that never gets recycled. Add it as a second condition: identity lives in the immutable ID, branch scoping stays in sub.

"StringEquals": {
  "token.actions.githubusercontent.com:repository_id": "456789",
  "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
Enter fullscreen mode Exit fullscreen mode
  1. Pin the action to a full 40-character commit SHA. configure-aws-credentials is at v6.1.0 as of this writing (v5.1.1 shipped 2025-11-24). A floating @v4 tag can be re-pointed by anyone who compromises the tag, and this action handles your credentials. It is the last place you want a mutable reference. Pin the SHA, leave the human-readable version in a comment, and do the same for every third-party action in the credential path.
- uses: aws-actions/configure-aws-credentials@<full-40-char-sha>  # v6.1.0
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-deploy
    aws-region: us-east-1
    role-session-name: gha-${{ github.run_id }}
Enter fullscreen mode Exit fullscreen mode
  1. Set role-session-name so CloudTrail can tell you which run did what. Skip it, and every assumed session looks identical in CloudTrail. Stamp the run ID or repo into the session name and it becomes an audit trail: an unexpected S3 write traces straight back to one workflow run. Pair it with a short role-duration-seconds. The default is one hour, but a deploy job that finishes in three minutes has no business holding a token for sixty. Drop it to 900 and a leaked token expires before anyone can use it.

  2. One narrow role per repo-and-environment, least privilege on the permissions policy. The trust policy decides who can assume the role. The permissions policy decides what they can do, and this is where most setups quietly over-grant. Do not attach AdministratorAccess to a deploy role, ever. Scope it to the exact actions and resource ARNs the job touches, and keep separate roles for prod and staging so a staging workflow can never reach production. Gate the prod role behind a GitHub Environment with required reviewers, then pin its sub to environment:prod.

  3. When it fails, decode the token instead of guessing. The AccessDenied message never tells you which claim mismatched, which is maddening the first time and routine after that. Print the actual JWT payload from inside the failing job and compare it byte for byte against your trust policy conditions.

curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" \
  | jq -r '.value' | cut -d. -f2 | base64 -d 2>/dev/null | jq .
Enter fullscreen mode Exit fullscreen mode

Reusable workflows hide a trap here: the sub reflects the calling repo, not the reusable one. If you need to trust the reusable workflow itself, condition on the job_workflow_ref claim instead of sub, or the match never fires and you will stare at the trust policy for an hour wondering why.

Wrap-up

If you do one thing, do this: define at least one exact-match condition on both aud and an identity claim, and make that identity claim the immutable repository_id, not the repo name. That single habit buys you least-privilege scoping today, survives the July 15, 2026 immutable-claim rollout, and closes the rename-squatting hole the change exists to fix. The static key you delete afterward is the entire point. A credential that does not exist cannot leak.

Sources


Originally published at indragustiprasetya.com

Top comments (0)