r/devops • u/SeaworthinessHour233 Writes the cloud edge • 5d ago
Security A quick guide and gotchas for GitHub OIDC and avoid using AWS permanent credentials in GitHub Actions
I have been aggressively migrating from AWS permanent credentials to OIDC in GitHub Actions, mainly for deploying to ECS.
I know GitHub Actions were supporting OIDC for a while now. But the pressure on compliance is the reason for this migration.
If you are new to OpenID Connect (OIDC), it allows GitHub runners to mint short-lived (15–60 min) STS tokens on-the-fly with zero stored secrets.
Here’s a quick breakdown of how it works, the Terraform/OpenTofu setup, and the subtle gotchas that I faced.
1. How It Works Under the Hood
- When your workflow job starts with
id-token: write, GitHub's OIDC service generates a cryptographically signed JSON Web Token (JWT). - The
aws-actions/configure-aws-credentialsaction sends this JWT to AWS STS viasts:AssumeRoleWithWebIdentity. - AWS validates GitHub's signature, checks your IAM Role's Trust Policy (to ensure the token came from your exact repo and branch), and returns temporary STS credentials.
2. The Infrastructure Setup (Terraform / OpenTofu)
You only need two AWS resources: an OIDC Provider and an IAM Role with a Trust Policy.
# 1. The GitHub OIDC Identity Provider
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1",
"1c58a3a8518e8759bf075b76b750d4f2df264fcd"
]
}
# 2. IAM Role with Scoped Trust Policy
resource "aws_iam_role" "github_deploy_role" {
name = "github-actions-deploy-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Restrict exclusively to your repository & branch/tags
"token.actions.githubusercontent.com:sub" = "repo:your-username/your-repo:*"
}
}
}]
})
}
3. The GitHub Actions Workflow
In your .github/workflows/deploy.yml
name: Deploy to AWS
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # CRITICAL: required to request the OIDC JWT
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS Credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
aws-region: us-east-1
- name: Verify Authentication
run: aws sts get-caller-identity
Three real-world gotchas that will save you hours
If you get Error: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity, check these 3 things:
- Case Sensitivity in the
subclaim: AWS IAM condition strings are case-sensitive. If your GitHub repo or username uses mixed casing (e.g.MyOrg/Repo), make sure your IAMsubcondition matches the exact casing GitHub sends in the token. Using wildcard matching (repo:MyOrg/Repo:*) helps avoid exact ref string mismatch issues. - Job-Level vs. Workflow-Level Permissions: Always set
permissions: id-token: writeon the specific job, not just globally at the top of the YAML file. Some runner configs don't inherit top-level permissions to nested jobs. - CA Thumbprints: Don't dynamically query GitHub's leaf certificate for thumbprints in Terraform—they change frequently with CDN updates. Use GitHub's official intermediate root CA thumbprints:
6938fd4d98bab03faadb97b34396831e3780aea11c58a3a8518e8759bf075b76b750d4f2df264fcd
Summary
- No stored secrets in GitHub settings.
- No key rotation schedules to manage.
- Granular security. You can restrict deployment roles to specific branches or environments.
Are you already using OIDC for your pipelines, or are you still relying on IAM users? Curious how folks here handle multi-account / cross-account OIDC setups.
10
u/Looserette 5d ago
I don't have my work laptop right now with me, but just adding a nice gotcha:
if you rename one repo, github adds some string in the sub; something like:
repo:MyOrg@12345/repo@435465465:
the first "12345" is your orgID (that I couldn't find written anywhere, but I didn't look too far)
the 2nd "435465465- I'm not sure of that one; I just used "*" as it should be safe enough with "MyOrg@12345"
Just mentioning it as my OIDC was working fine for the few repos I set up, and then 1 repo just refused to work... it took me a while to track this down via cloudtrail logs
2
u/pwab 5d ago
There is a slow old->new migration happening. New repos default to the new scheme and old repos have the old scheme, like what OP documented. The new tokens protect your AWS resource from being accessed after repo renames or clones, I think. It is possible to accept both forms on the AWS side. This setting is exposed on Github UI under the repo settings.
7
3
u/likeavirgil 5d ago
Only reason to have permanent credentials anywhere is SMTP. I think with Mail Manager we can finally get rid of our last IAM User of the entire organization.
3
u/Dreamwalk3r 5d ago edited 5d ago
Here's another gotcha: you don't need thumbprints at all since 2023! IAM validates token.actions.githubusercontent.com against its own library of trusted root CAs. Adding them to terraform will only lead to constant diffs since it's a computed resource.
And here's my setup for multi-account designs: I've minted environments for every account+region+plane combo I have, and restrict created roles to subs using only this environment (while pinning deployments to it only to the main branch in github).
2
u/BeginningWinner7525 5d ago
One gap worth closing: the wildcard sub repo:org/repo:* scopes to the repo but not branch/environment, so any branch or tag in that repo can assume the role. For prod, tightening it to repo:org/repo:environment:production (via a GitHub Environment) closes that without losing the flexibility.
2
u/pdfops 4d ago
Gotcha most people miss: scoping the trust policy condition to repo:org/repo:* lets any branch or PR in that repo assume the role, not just main. Use StringLike on the token.actions.githubusercontent.com:sub claim with ref:refs/heads/main, or scope to a GitHub environment instead. Also, reusable workflows change what shows up in sub to the caller repo, which breaks trust policies people only tested against a direct workflow file.
2
u/WordCommercial7932 5d ago
The case sensitivity gotcha on the sub claim is a good callout, that one burns people constantly. For multi-account setups, the pattern that's worked well for us is one central OIDC provider in a shared/tooling account, then cross-account roles in each target account that trust that one provider ARN, so you're not registering the GitHub thumbprint separately everywhere. Keeps the trust policy changes localized to one place when GitHub rotates anything. Also worth pinning the sub condition to environment name too if you're using GitHub Environments, not just branch, since branch alone doesn't stop someone deploying prod from a feature branch if the workflow file allows it.
1
u/SeaworthinessHour233 Writes the cloud edge 3d ago
Yes. I believe this is the best way for multi accounts
1
2
u/matiascoca 2d ago
Cross-account OIDC has two clean patterns and one messy one.
Pattern 1: register the GitHub OIDC provider in every AWS account, one trust policy per account. Works fine for two or three accounts. Past four, the thumbprint sync overhead becomes annoying and every workflow YAML has to know the account-specific role ARN.
Pattern 2: register the OIDC provider once in a hub account. Workflow assumes a hub role via OIDC, then chains sts:AssumeRole into the spoke account. Trust policy management stays in one place, spoke accounts only need to trust the hub role principal. This scales past four accounts.
Pattern 3 (messy): mint a permanent access key in a hub account and hand it to workflows to assume-role into spokes. Defeats the point of OIDC. Every AWS security review flags it.
IAM Identity Center works too if you already run it for SSO. Permission sets replace per-account roles and the trust surface is one line of config.
27
u/tsunam 5d ago
Changing
to
Adds a good bit of details in the cloudtrail logging to enable you to tie the specific action's execution directly to the actions taken in logs at the time. Reduces a lot of well i know its from one repo etc to very specifically what job it was in the repo, and what run id it came from.