writing / aws

Stop Storing AWS Keys in GitHub Secrets

Use OpenID Connect to let GitHub Actions assume an AWS IAM role and push to ECR — no long-lived access keys, ever.

Atharva Uday UndeAtharva Uday UndeJuly 9, 20269 min read
GitHub ActionsAWSOIDCECRNode.jsExpressCI/CDIAMDockerDevOps

The Problem With `AWS_ACCESS_KEY_ID` in Secrets

Most "deploy to AWS" tutorials start the same way: create an IAM user, generate an access key, paste it into GitHub Actions secrets. It works on day one.

Then reality sets in:

  1. The key never expires. It sits in GitHub's secret store until someone remembers to rotate it. Nobody remembers.
  2. It's a static credential outside AWS's control. If GitHub secrets leak (misconfigured workflow, malicious PR from a fork, compromised dependency), the key is usable from anywhere, indefinitely, until you catch it.
  3. One key, many repos. Teams reuse the same IAM user across pipelines because creating a new one is friction. Blast radius grows with every repo that gets access.
  4. No caller identity in CloudTrail beyond the IAM user. You can't tell which workflow run, which repo, or which branch actually made the call.

None of this is necessary. GitHub Actions can request short-lived AWS credentials directly from AWS STS, scoped to a specific repo and branch, with zero secrets stored anywhere.

That's OIDC federation.


The Solution: OIDC Federation

GitHub Actions can issue a signed OIDC token for every workflow run. AWS can trust that token as an identity provider and let it assume an IAM role — no access key required.

1. Register GitHub's OIDC provider in AWS IAM
   ↓
2. Create an IAM role that trusts tokens from that provider
   ↓
3. Scope the trust policy to your specific repo (and branch, if you want)
   ↓
4. Workflow requests a token from GitHub, exchanges it for temporary AWS creds via STS
   ↓
5. Role's permissions determine what the workflow can do (e.g., push to one ECR repo)
   ↓
6. Credentials expire when the job ends. Nothing persists.

No secrets in GitHub. No key rotation. No shared IAM user across ten pipelines.


Step 1: Register GitHub as an OIDC Provider

In IAM, create an identity provider trusting GitHub's token issuer:

  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

You only do this once per AWS account — every repo's workflows can trust the same provider.

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list a031c46782e6e6c662c2c87c76da9aa62ccabd8e

AWS validates the token signature directly now, but the provider resource still requires a thumbprint value at creation time.


Step 2: Create the IAM Role (Scoped to One Repo)

This is the part people get wrong. Don't trust repo:*. Scope the trust policy to your exact repository, and ideally the branch too.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "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:atharvaunde/hello-express-ecr:ref:refs/heads/main"
        }
      }
    }
  ]
}

Attach a permissions policy that only allows what the workflow actually needs — ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:PutImage, ecr:InitiateLayerUpload, ecr:UploadLayerPart, ecr:CompleteLayerUpload — scoped to one repository ARN. Not ecr:* on *.


Step 3: The Node.js Hello World

Nothing fancy. A minimal Express app to prove the pipeline end-to-end.

// index.js
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;

app.get("/", (req, res) => {
  res.json({ message: "Hello from ECR", version: process.env.APP_VERSION || "dev" });
});

app.get("/health", (req, res) => res.status(200).send("ok"));

app.listen(port, () => console.log(`Listening on ${port}`));
// package.json
{
  "name": "hello-express-ecr",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": { "start": "node index.js" },
  "dependencies": { "express": "^4.19.2" }
}
# Dockerfile
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM node:24-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build --chown=appuser:appgroup /app /app
USER appuser
EXPOSE 3000
CMD ["node", "index.js"]

Step 4: Create the ECR Repository

aws ecr create-repository \
  --repository-name hello-express-ecr \
  --image-scanning-configuration scanOnPush=true

scanOnPush catches known CVEs in your base image on every push. Free, and there's no reason to skip it.


Step 5: The Workflow

This is the entire pipeline — checkout, assume role via OIDC, log in to ECR, build, push.

# .github/workflows/build-push-ecr.yml
name: Build and Push to ECR

on:
  push:
    branches: [main]

permissions:
  id-token: write # required to request the OIDC token
  contents: read # required to checkout the repo

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: hello-express-ecr

jobs:
  build-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v6.1.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2.1.5

      - name: Build, tag, and push image
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker tag $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $REGISTRY/$ECR_REPOSITORY:latest
          docker push $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $REGISTRY/$ECR_REPOSITORY:latest

No AWS_ACCESS_KEY_ID. No AWS_SECRET_ACCESS_KEY. Nothing to rotate, nothing to leak.


OIDC vs Static Keys: Side by Side

Static IAM User Keys OIDC Federation
Credential lifetime Indefinite until rotated manually Minutes — expires with the job
Stored in GitHub secrets Yes, permanently No, nothing stored
Scope Often shared across repos One role per repo/branch, least-priv
Leak blast radius Usable from anywhere, until revoked Token is single-use, tied to that run
CloudTrail visibility Shows the IAM user only Shows repo, branch, and workflow run
Rotation burden Manual, usually skipped None — AWS handles it

What I Actually Do

I scope every OIDC trust policy to repo:org/name:ref:refs/heads/main — not a wildcard on the repo, and not a wildcard on the ref. If a workflow only needs to push from main, that's the only ref that can assume the role. Anyone pushing from a feature branch or a fork PR gets an explicit AccessDenied, which is exactly what should happen.

I also give each repo its own role and its own ECR repository permissions. It's more IAM resources to manage, but a compromised workflow in one repo can't touch another team's registry. Wildcarding ecr:* on * to save five minutes of setup is how a single leaked pipeline becomes a full-registry incident.


Common Mistakes

Mistake #1: Trust policy uses repo:org/* instead of the exact repo name.

  • Why it's wrong: Any repo in the org can assume the role. That's not scoping, that's an org-wide credential with extra steps.
  • What to do: Pin sub to the exact repo, and the branch or tag pattern if the role is deploy-only.

Mistake #2: Forgetting permissions: id-token: write on the workflow or job.

  • Why it's wrong: Without it, GitHub won't issue the OIDC token and configure-aws-credentials fails with an opaque "Not authorized to perform sts:AssumeRoleWithWebIdentity" error.
  • What to do: Set it explicitly — it's not on by default, even if your workflow has broad repo permissions elsewhere.

Mistake #3: Attaching AdministratorAccess or AmazonEC2ContainerRegistryFullAccess to the role because scoping ECR actions felt tedious.

  • Why it's wrong: OIDC fixes the credential lifetime problem, not the blast-radius problem. An over-permissioned role is still an over-permissioned role.
  • What to do: Scope the permissions policy to the exact ECR actions and repository ARN this pipeline needs.

TL;DR

Problem: Long-lived AWS access keys stored in GitHub secrets never expire, get shared across pipelines, and leave a thin audit trail.

Solution: Register GitHub as an OIDC provider in AWS IAM, create a role trusting only your specific repo and branch, and use aws-actions/configure-aws-credentials to assume it. Zero secrets stored, credentials expire with the job.

Result: Every push to ECR is traceable to a specific repo, branch, and workflow run — with nothing sitting in GitHub waiting to leak.

If your workflow still has AWS_ACCESS_KEY_ID in its secrets, that's a fifteen-minute fix.


Tags: GitHub Actions · AWS · OIDC · ECR · Node.js · Express · CI/CD · IAM · Docker · DevOps