Back to articles
AWS / DevOpsJuly 28, 20266 min read

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

code read-only
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:

json read-only
{
  "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)

yaml read-only
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 ci vs npm install: npm ci does a clean, deterministic install from package-lock.json. It's faster in CI (skips the dependency resolution step) and ensures the exact same package versions every run. Never use npm install in CI pipelines.

---

The Deploy Workflow (OIDC Auth + Versioned S3 Artifact)

yaml read-only
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:

code read-only
# 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:

code read-only
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity

Fix: Use StringLike with a wildcard pattern that accommodates the numeric IDs:

json read-only
"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:

json read-only
"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.

yaml read-only
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

  • OIDC federation eliminates stored access keys. Short-lived tokens (~15 min) scoped to a specific workflow run are orders of magnitude safer than long-lived IAM user keys.
  • 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.
  • The sub claim can include numeric IDs. Use StringLike with wildcards in your trust policy to handle GitHub's internal ID format.
  • Versioned S3 artifacts over s3 sync. Timestamped + commit-SHA zip files give you a complete deployment history and enable rollback.
  • Run tests in both CI and deploy. CI validates every branch; the deploy job re-runs tests as a safety gate before touching production infrastructure.
  • P
    Pratik Sangani
    Backend Developer & Architect
    Get in touch