| name | github-actions-oidc-aws-ecr |
| description | Set up keyless GitHub Actions to AWS authentication via OIDC to push container images to ECR (or assume any IAM role) with no static keys. Use when a CI workflow needs to push to ECR, assume an AWS role from GitHub Actions, configure aws-actions/configure-aws-credentials, write an OIDC trust policy, or migrate a GitLab CI / Bitbucket pipeline to GitHub Actions with AWS access. Covers trust-policy sub scoping, least-privilege ECR push, the id-token:write gotcha, Terraform vs Console vs access-request provisioning, and banking constraints (permissions boundary, SHA-pinning, drift). |
| license | MIT |
| metadata | {"version":"1.1","hermes":{"tags":["GitHub-Actions","AWS","OIDC","ECR","IAM","CI-CD"],"related_skills":[]}} |
Setting Up GitHub Actions OIDC to AWS ECR
Purpose: Wire a GitHub Actions workflow to push a Docker image to AWS ECR using short-lived OIDC credentials — no static AWS keys. Generalizes to any GHA→AWS assume-role.
The mechanism
GitHub mints an OIDC JWT per run → workflow calls sts:AssumeRoleWithWebIdentity → AWS checks the role's trust policy (aud + sub claims) → returns short-lived creds → ECR login + push. The wiring is ~20 lines; the work is the AWS-side IAM role, which is usually access-gated, not knowledge-gated.
Trust policy — exact, not wildcard
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<ACCOUNT>:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<ORG>/<REPO>:ref:refs/heads/main"
}
}
}]
}
StringEquals for an exact sub — StringLike only when the value has a *. Multiple exact refs → an array of sub values (still StringEquals).
- Never
repo:<ORG>/<REPO>:* — security rejects it. Scope to a ref, an array of refs, or :environment:<env>.
- The OIDC provider is account-wide, one-time:
aws iam list-open-id-connect-providers before creating — duplicates fail. AWS trusts GitHub's CA since 2023, so the thumbprint is vestigial (optional in Terraform AWS provider v5+).
Permissions policy — least-privilege ECR push
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "ECRAuth", "Effect": "Allow", "Action": "ecr:GetAuthorizationToken", "Resource": "*" },
{ "Sid": "ECRPush", "Effect": "Allow",
"Action": ["ecr:BatchCheckLayerAvailability","ecr:InitiateLayerUpload","ecr:UploadLayerPart","ecr:CompleteLayerUpload","ecr:PutImage"],
"Resource": "arn:aws:ecr:<REGION>:<ACCOUNT>:repository/<REPO>" }
]
}
ecr:GetAuthorizationToken has no resource-level scoping — must be Resource: "*". Flag it so reviewers don't read it as over-broad.
- Drop
ecr:BatchGetImage (read/pull) for pure push — add back only if the build pulls a cached layer from the same repo.
Workflow YAML
permissions:
id-token: write
contents: read
jobs:
build-and-push:
runs-on: <runner-label>
steps:
- uses: actions/checkout@<sha>
- run: docker build -t local-image:${{ github.sha }} .
- name: Configure AWS credentials (OIDC)
if: github.ref == 'refs/heads/main'
uses: aws-actions/configure-aws-credentials@<sha>
with:
role-to-assume: arn:aws:iam::<ACCOUNT>:role/<ROLE>
aws-region: <REGION>
- name: Login to ECR
id: ecr
if: github.ref == 'refs/heads/main'
uses: aws-actions/amazon-ecr-login@<sha>
- name: Tag & push
if: github.ref == 'refs/heads/main'
env:
REGISTRY: ${{ steps.ecr.outputs.registry }}
REPOSITORY: <REPO>
IMAGE_TAG: ${{ github.sha }}
run: |
docker tag local-image:${{ github.sha }} "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
docker push "$REGISTRY/$REPOSITORY:$IMAGE_TAG"
- Push in the same job as build — the image lives in that runner's local Docker daemon; a separate
needs: job needs save/load.
- SHA-pin all actions (supply-chain; banks mandate). Resolve:
gh api repos/aws-actions/configure-aws-credentials/commits/v4 --jq .sha.
if: main aligned with a main-only trust sub → feature/PR runs skip OIDC, no 403, no trust widening. First test: workflow_dispatch on main.
Three provisioning routes — pick by what you're allowed to do
| Route | When | Note |
|---|
| Access request | No IAM-write; Security owns the account | Send the two policies; ask for the role ARN back |
| Terraform / IaC | You have an apply path (direct or PR-to-pipeline) | Provider-conditional (data vs resource); production-correct route |
| Console click-ops | Sandbox, no CLI | Throwaway; verify the wizard's trust JSON — it often emits a wildcard sub |
Switching route changes the tool, not the gate — all three need IAM-write (iam:CreateRole, iam:CreateOpenIDConnectProvider, iam:PutRolePolicy) plus the permissions boundary.
Banking / regulated-account gotchas
- Permissions boundary: most accounts deny
iam:CreateRole unless the new role carries a mandated boundary ARN (permissions_boundary in Terraform). Blocks creation more often than the trust policy does.
- Console wizard loosens the sub: the Web-identity GitHub helper frequently writes
repo:<org>/<repo>:*. Edit the trust JSON to the exact StringEquals form.
- Manual creation = drift: a hand-made role in a shared IaC-managed account may be SCP-denied or reverted by reconciliation. Manual is sandbox-only; production needs IaC.
403 triage
- Missing
id-token: write in workflow permissions: — check first; the silent failure.
sub mismatch — wildcard left by wizard, or wrong org/branch.
- Wrong
aud (must be sts.amazonaws.com for the official action).
- Missing
ecr:GetAuthorizationToken or wrong repo ARN in the permissions policy.
Canonical references
GitHub Docs "Configuring OpenID Connect in AWS" + the aws-actions/configure-aws-credentials README — the authoritative wiring spec, faster than any course.