Ditch IAM access keys: GitHub Actions OIDC to AWS
If your GitHub Actions workflows still authenticate to AWS with a stored
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY pair, you’re carrying a
long-lived credential that can leak from a log, a fork’s pull_request_target
run, or a compromised dependency — and it keeps working until someone
remembers to rotate it. OpenID Connect (OIDC) federation gets rid of that
entirely: GitHub mints a short-lived, workflow-scoped identity token, AWS STS
trades it for temporary credentials, and there’s nothing sitting in your repo
secrets for an attacker to steal. This is the same pattern I use for this
site’s own deploy pipeline, so what follows is the working setup, not just
the AWS docs paraphrased.
How the trust actually works
- GitHub Actions exposes an OIDC provider at
https://token.actions.githubusercontent.com. Every workflow run can request a signed JWT from it (viaid-token: writepermission) with claims describing the repo, branch, and workflow. - You register that provider as an IAM OIDC identity provider in your AWS account, once.
- You create an IAM role whose trust policy says “I’ll accept tokens from
that provider, but only if the
subclaim matches this specific repo and ref.” - The
aws-actions/configure-aws-credentialsaction exchanges the JWT for temporary STS credentials scoped to that role, valid for the run only.
No secret ever leaves GitHub’s control plane. Nothing to rotate, nothing to revoke except the role’s trust policy.
Step 1: create the OIDC provider
Do this once per AWS account (Terraform, since you’re presumably managing the rest of your IAM this way too):
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
That thumbprint is GitHub’s OIDC endpoint CA thumbprint. Gotcha: AWS actually ignores this field for GitHub’s provider now (it validates via the standard TLS CA bundle instead), but the argument is still required by the resource — don’t spend time trying to keep it “current,” it’s a legacy requirement AWS kept for backward compatibility.
Step 2: write a trust policy scoped tighter than you think you need
This is where most setups go wrong. The sub claim format is
repo:<org>/<repo>:<qualifier>, and it’s tempting to wildcard it into
uselessness:
{
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/jstgtech-web:ref:refs/heads/main"
}
}
}
Two things to get right:
- Always set the
audcondition. Without it, any GitHub Actions run anywhere that requests a token forsts.amazonaws.comaudience and happens to match yoursubpattern can assume the role.audis cheap insurance and AWS’s own quickstart includes it — don’t skip it because the console wizard makes it feel optional. - Scope
subto the exact ref, not just the repo.repo:my-org/my-repo:*lets a PR from a fork-turned-branch, a tag push, or anenvironment:deployment all assume the same role your production deploy uses. If a workflow only needs to deploy frommain, pinsubtorepo:my-org/jstgtech-web:ref:refs/heads/main. If you use GitHub Environments for a manual-approval gate, scope torepo:my-org/jstgtech-web:environment:productioninstead — that ties the AWS role to the same approval gate protecting your environment secrets.
For a PR-only workflow (say, terraform plan on pull requests, no write
access), use a separate, more restrictive role with sub matching
repo:my-org/jstgtech-web:pull_request and a read-only policy attached —
don’t reuse your deploy role’s trust policy with a broader condition “just
for now.”
Step 3: use it in the workflow
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-site-deploy
aws-region: us-east-1
permissions: id-token: write is not optional — it’s what lets the runner
request the JWT in the first place, and GitHub defaults this to none at
the org or repo level on newer accounts. If you get `Error: Not authorized
to perform sts:AssumeRoleWithWebIdentity`, check this before anything else;
it’s the single most common cause, ahead of trust-policy typos.
Trade-offs worth knowing before you migrate
- Session duration is capped by the role, not the token. The GitHub JWT
is short-lived by nature, but your role’s
MaxSessionDurationstill governs how long the assumed credentials last. Keep it at the default (1 hour) or lower for deploy roles — there’s no reason a CI job needs an 8-hour session. - Cross-account deploys need per-account providers. The OIDC provider and trust policy live in the AWS account being deployed into, not in some central account. If you deploy to three accounts (dev/stage/prod), you need the provider registered in each, with role names and trust conditions matched to that account’s own audience.
- Self-hosted runners change the token issuer. If you ever move a
workflow to self-hosted runners inside your own VPC, the token still comes
from
token.actions.githubusercontent.com(GitHub issues it, not the runner), so this setup doesn’t need to change — but it’s worth confirming if you’re debugging a runner migration and OIDC suddenly stops working for an unrelated reason (usually a network path to GitHub’s OIDC endpoint from the runner). - You still need least-privilege on the role’s permissions policy.
OIDC only fixes how the workflow authenticates, not what it’s allowed
to do once authenticated. A perfectly scoped trust policy attached to a
role with
AdministratorAccessis still one compromised Action away from a bad day — scope the permissions policy to exactly the S3 bucket, CloudFront distribution, or Terraform state path the workflow touches.
Rolling it out without a big-bang cutover
If you’re migrating an existing pipeline off access keys, run both in
parallel for one deploy cycle: add the OIDC role, switch
configure-aws-credentials to role-to-assume, watch a real deploy
succeed, then delete the IAM user and its access keys. Don’t delete the
old credentials in the same PR that introduces the new role — if the trust
policy’s sub condition is wrong, you want a fallback for the next deploy
instead of a broken pipeline and no way back in until you fix IAM by hand.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cutting NAT gateway costs with VPC endpoints that actually help
How gateway and interface VPC endpoints replace NAT gateway traffic for AWS API calls, what they cost instead, and which traffic still has to go through NAT.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.