Keyless AWS Authentication in GitHub Actions with OIDC Federation
Build a production-grade CI/CD pipeline using GitHub Actions with IAM OIDC identity federation for keyless AWS authentication. No stored access keys, no secrets rotation — just short-lived tokens scoped to your repository. Covers OIDC setup, trust policy scoping, real-world claim matching bugs, and versioned S3 artifact deployment.
Storing AWS access keys in GitHub secrets is a common pattern — and a common security risk. Long-lived keys can be leaked in logs, checked into repositories by mistake, or stolen from CI environments. There's a better way: IAM OIDC identity federation.
With OIDC, GitHub Actions requests a short-lived token (~15 minutes) from GitHub's OIDC provider. AWS verifies the token and allows GitHub to assume an IAM role — no access keys stored anywhere.
How OIDC Federation Works
git push → GitHub Actions starts
│
│ requests OIDC token
▼
GitHub OIDC Provider
│ issues JWT with claims:
│ sub: "repo:pratiksanganii/my-cicd-app:ref:refs/heads/main"
│ aud: "sts.amazonaws.com"
▼
AWS STS AssumeRoleWithWebIdentity
│ validates JWT signature against
│ token.actions.githubusercontent.com
▼
Short-lived AWS credentials (15 min)
│
▼
S3 Upload, ECR Push, etc.
The OIDC token contains claims — key-value pairs that describe the workflow context. AWS evaluates these claims against the IAM role's trust policy before granting access.
---
Setting Up the IAM OIDC Provider
Only one OIDC provider is needed per AWS account for all GitHub repositories:
1. IAM → Identity providers → Add provider
2. Provider type: OpenID Connect
3. Provider URL: https://token.actions.githubusercontent.com
4. Audience: sts.amazonaws.com
Do I need to manually verify the thumbprint? No. GitHub's OIDC provider uses a certificate signed by a trusted root CA that AWS already trusts. AWS populates the thumbprint field automatically but doesn't actually rely on it for validation — the CA chain handles security.
---
The IAM Trust Policy (Scoping to Your Repository)
The trust policy is what makes this secure. It restricts which GitHub repositories can assume the role:
{
"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:pratiksanganii@/my-cicd-app@:*"
}
}
}]
}
The sub claim follows the format: repo:. Using StringLike with * allows the role to be assumed from any branch, tag, or environment in that repository.
---
The CI Workflow (Lint + Test on Every Push)
name: CI Pipeline
on:
push:
branches: ['*']
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm' # Caches node_modules between runs
- run: npm ci # Deterministic install from package-lock.json
- run: npm run lint
- run: npm test
npm civsnpm install:npm cidoes a clean, deterministic install frompackage-lock.json. It's faster in CI (skips the dependency resolution step) and ensures the exact same package versions every run. Never usenpm installin CI pipelines.
---
The Deploy Workflow (OIDC Auth + Versioned S3 Artifact)
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write # Required: allows the workflow to request an OIDC token
contents: read # Required: allows actions/checkout to clone the repo
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm test # Tests run again as a safety gate before deployment
- name: Package artifact
run: |
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
SHORT_SHA=${GITHUB_SHA::7}
ARTIFACT_NAME="deploy-${TIMESTAMP}-${SHORT_SHA}.zip"
zip -r "$ARTIFACT_NAME" src/ package.json package-lock.json \
--exclude "node_modules/" --exclude "tests/" --exclude ".github/*"
echo "ARTIFACT_NAME=$ARTIFACT_NAME" >> $GITHUB_ENV
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActionsRole
aws-region: ap-south-1
- name: Upload artifact to S3
run: |
aws s3 cp "$ARTIFACT_NAME" s3://${{ secrets.S3_BUCKET_NAME }}/artifacts/
echo "✅ Uploaded: s3://${{ secrets.S3_BUCKET_NAME }}/artifacts/$ARTIFACT_NAME"
Why a versioned zip instead of s3 sync? Each deployment produces a uniquely named artifact (deploy-20260729-143000-a1b2c3d.zip). This gives you a full deployment history in S3 — you can see what was deployed and when, and roll back by re-deploying an older zip.
---
The Real-World OIDC Bug: Numeric ID in Sub Claims
This is the edge case that burned me and isn't well-documented.
GitHub's OIDC sub claim sometimes includes internal numeric IDs alongside the human-readable username and repository name:
# Expected:
repo:pratiksanganii/my-cicd-app:ref:refs/heads/main
Actual (with numeric IDs):
repo:pratiksanganii@123456/my-cicd-app@789012:ref:refs/heads/main
If your trust policy uses StringEquals instead of StringLike, or if the StringLike pattern doesn't account for the @ suffix, the OIDC authentication silently fails with:
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity
Fix: Use StringLike with a wildcard pattern that accommodates the numeric IDs:
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:pratiksanganii@/my-cicd-app@:ref:refs/heads/*"
}
Alternatively, scope the trust policy by job_workflow_ref instead of sub, which is more stable:
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": "pratiksanganii/my-cicd-app/.github/workflows/deploy.yml@refs/heads/*"
}
---
Only Two GitHub Secrets Needed
The entire pipeline requires only two repository secrets — not AWS access keys:
| Secret | Value |
|---|---|
| AWS_ACCOUNT_ID | Your 12-digit AWS account ID |
| S3_BUCKET_NAME | The target S3 bucket name |
The account ID isn't a password, but keeping it out of the YAML file makes the workflow portable across environments.
---
The workflow_run Dependency Pattern
The deploy workflow uses needs: build-and-test to enforce that deployment only runs if CI passes. But there's a subtlety: even if you run tests again in the deploy job (as a safety gate), the needs dependency ensures the deploy job doesn't even start if CI failed.
deploy:
needs: build-and-test # Deploy only runs if CI passes
if: github.ref == 'refs/heads/main'
This is the correct architecture: CI runs on every push, deploy runs only on main after CI passes.
---
Key Takeaways
permissions: id-token: write is required. Without this in your workflow YAML, GitHub won't generate the OIDC token and the AWS credentials step fails silently.sub claim can include numeric IDs. Use StringLike with wildcards in your trust policy to handle GitHub's internal ID format.s3 sync. Timestamped + commit-SHA zip files give you a complete deployment history and enable rollback.