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:
- The key never expires. It sits in GitHub's secret store until someone remembers to rotate it. Nobody remembers.
- 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.
- 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.
- 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
subto 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-credentialsfails 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