Configure and review AWS auth for GitHub Actions CI/CD — OIDC role assumption, ECR push perms, ECS deploy perms, least-privilege IAM. Not for application IAM/Cognito, not for GCP.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add atretyak1985/swarmery --skill aws-cicd-auth --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Aws Cicd Auth?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/atretyak1985-aws-cicd-auth)More formats (shields.io, HTML) on the badges page.
---
name: aws-cicd-auth
description: "Configure and review AWS auth for GitHub Actions CI/CD — OIDC role assumption, ECR push perms, ECS deploy perms, least-privilege IAM. Not for application IAM/Cognito, not for GCP."
version: "1.0.0"
owner: "swarmery-infra"
docs:
status: reviewed
source_sha: b4133a319aa1
updated: 2026-08-06
---
# Purpose
Define, configure, and review how the project's GitHub Actions workflows authenticate to AWS to push container images and trigger deployments. The pattern is **keyless OIDC** — GitHub Actions exchanges a short-lived OIDC token for temporary AWS STS credentials by assuming an IAM role. **No long-lived AWS access keys are stored anywhere** (not in repo secrets, not in `~/.aws`). The only secrets stored are non-sensitive identifiers (role ARN, region, repo/cluster/service names).
The reference implementation is the API repo's (`project.json` → `mainApp`) `.github/workflows/deploy-prod.yml`: it assumes an IAM role via `aws-actions/configure-aws-credentials@v4`, logs into **Amazon ECR**, builds/pushes the image, and forces a new **Amazon ECS** deployment.
# When to use
- Setting up AWS auth for a new repo or service in the project that needs to push to ECR / deploy to ECS from GitHub Actions.
- Reviewing an existing OIDC auth stanza (`permissions: id-token: write`, `configure-aws-credentials`, role ARN scoping).
- Authoring or auditing the IAM **trust policy** (who can assume the CI role — the `sub`/`aud` conditions).
- Authoring or auditing the IAM **permission policy** (what the CI role can do — ECR push + ECS deploy, least-privilege).
- Debugging an auth failure: `Error: Could not assume role`, `Not authorized to perform sts:AssumeRoleWithWebIdentity`, ECR `denied`, ECS `AccessDenied`.
- Adding a new repo/service to the existing OIDC pattern.
# When NOT to use
- **Overall CI/CD pipeline structure** (job/stage ordering, `workflow_run` triggers, concurrency, build matrix) — use `github-actions-cicd`.
- **Docker image build/tag/push mechanics** (Dockerfile, layer caching, tag strategy) — use `docker-build`.
- **Application-level AWS IAM**, Cognito, runtime task-role permissions the app uses at runtime, or end-user auth — out of scope (this skill is CI identity only).
- **GCP / any non-AWS cloud** — this skill is AWS-only; for GCP CI auth use `gcp-cicd-auth`.
# Required environment
- A repo with a `.github/workflows/*.yml` deploy workflow (template: the API repo's `deploy-prod.yml`).
- AWS account access to the IAM console (or `aws iam` CLI) to create/inspect the OIDC provider, role trust policy, and permission policy.
- A registered GitHub OIDC identity provider in the AWS account: `token.actions.githubusercontent.com` with audience `sts.amazonaws.com` (one per account, shared by all repos).
- GitHub repo/environment **secrets** populated: `AWS_ROLE_ARN`, `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE`.
# Inputs
| Input | Required | Description |
|-------|----------|-------------|
| Target repo | Yes | `OWNER/REPO` that will run the workflow (scopes the trust policy `sub`) |
| Branch / environment | Yes | Which ref or GitHub Environment may deploy (e.g. `refs/heads/main`, `environment:production`) |
| ECR repository name | Yes | Destination ECR repo (`ECR_REPOSITORY` secret) |
| ECS cluster + service | Yes | `ECS_CLUSTER` + `ECS_SERVICE` to force-redeploy |
| AWS region | Yes | `AWS_REGION` secret (must match where ECR repo + ECS cluster live) |
| Existing role ARN | No | If extending an existing CI role rather than creating one |
# Outputs
**Length budget:** Combined IAM JSON + workflow YAML output must not exceed 120 lines. Trust policy + permission policy together should be well under 60 lines — if a policy grows beyond that, it has stopped being least-privilege.
Deliverables:
- The workflow auth stanza (`permissions:` + `configure-aws-credentials@v4` step).
- The IAM **trust policy** JSON (OIDC provider principal + `sub`/`aud` conditions).
- The IAM **permission policy** JSON (ECR push + ECS deploy, least-privilege).
- The list of GitHub secrets to set and their (non-secret) values.
- Verification evidence: `aws sts get-caller-identity` output from a workflow run showing the assumed-role ARN.
# Procedure
## Step 1: Confirm the workflow requests an OIDC token
The job MUST grant the `id-token: write` permission or `configure-aws-credentials` has no OIDC token to exchange. Keep `contents: read` for checkout.
```yaml
permissions:
id-token: write # REQUIRED — without this, role assumption fails
contents: read
```
**Checkpoint:** `permissions.id-token` is `write` at the job (or workflow) level. Confirm no long-lived `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets exist — OIDC replaces them entirely.
## Step 2: Configure credentials via role assumption
Use the pinned action and role ARN + region from secrets. This step performs `sts:AssumeRoleWithWebIdentity` and exports short-lived (default ~1h) credentials into the job environment.
```yaml
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
```
**Checkpoint:** `role-to-assume` and `aws-region` both come from secrets (never hardcoded). No `aws-access-key-id`/`aws-secret-access-key` inputs are present.
## Step 3: Author the IAM trust policy (who may assume the role)
The role's trust policy makes the GitHub OIDC provider the principal and **scopes `sub` to the exact repo + ref (or environment)**. This is the single most security-critical line — a loose `sub` (e.g. `repo:OWNER/*:*`) lets any branch of any matching repo assume the role.
```json
{
"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",
"token.actions.githubusercontent.com:sub": "repo:<org>/<api-repo>:ref:refs/heads/main"
}
}
}
]
}
```
To gate on a GitHub **Environment** instead of a branch, use:
`"token.actions.githubusercontent.com:sub": "repo:<org>/<api-repo>:environment:production"`.
To allow several refs, switch the inner operator to `StringLike` with an array of explicit patterns — never collapse to a bare `*`.
**Checkpoint:** `aud` equals `sts.amazonaws.com`. `sub` names the exact repo AND a specific ref/environment. No wildcard spans repos or all branches.
## Step 4: Author the permission policy (what the role may do)
Grant only ECR push + ECS deploy. `ecr:GetAuthorizationToken` must be on `Resource: "*"` (it is account-scoped and AWS does not support resource-level scoping for it); the ECR layer/image actions are scoped to the one repo; ECS actions are scoped to the service. **Do not attach `AdministratorAccess` or `AmazonEC2ContainerRegistryFullAccess`.**
```json
{
"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_ID>:repository/<ECR_REPOSITORY>"
},
{
"Sid": "EcsDeploy",
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices"
],
"Resource": "arn:aws:ecs:<REGION>:<ACCOUNT_ID>:service/<ECS_CLUSTER>/<ECS_SERVICE>"
}
]
}
```
**Checkpoint:** Only the ECR + ECS actions above appear. `ecr:GetAuthorizationToken` is the only `Resource: "*"` entry. No `iam:*`, no `*:*`, no managed FullAccess/Admin policy attached.
## Step 5: Set GitHub secrets
Populate these as repo or environment secrets. None are sensitive AWS credentials — they are identifiers — but storing them as secrets keeps account IDs/ARNs out of the YAML.
| Secret | Example value |
|--------|---------------|
| `AWS_ROLE_ARN` | `arn:aws:iam::123456789012:role/api-deploy-role` |
| `AWS_REGION` | `eu-west-1` |
| `ECR_REPOSITORY` | `api-service` |
| `ECS_CLUSTER` | `prod-cluster` |
| `ECS_SERVICE` | `api-service` |
**Checkpoint:** `AWS_REGION` matches the region in the ECR + ECS ARNs from Step 4. `ECR_REPOSITORY` matches the repository ARN suffix.
## Step 6: Wire login, push, and deploy (reference flow)
```yaml
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
# build + docker push to $ECR_REGISTRY/$ECR_REPOSITORY (see docker-build skill)
- name: Force new deployment (ECS pulls latest)
run: |
aws ecs update-service \
--cluster "${{ secrets.ECS_CLUSTER }}" \
--service "${{ secrets.ECS_SERVICE }}" \
--force-new-deployment
```
**Checkpoint:** `amazon-ecr-login@v2` is the version pin. The `aws ecs update-service` cluster/service come from secrets, region is inherited from Step 2.
## Step 7: Verify the assumed identity
Add a one-off verification step (or run locally against the workflow logs) to prove which role was assumed:
```yaml
- name: Verify AWS identity
run: aws sts get-caller-identity
```
Expect an `Arn` of the form `arn:aws:sts::<ACCOUNT_ID>:assumed-role/api-deploy-role/<session>`. If this returns an unexpected role, or fails, the trust policy or `id-token` permission is wrong.
**Checkpoint:** `get-caller-identity` returns the expected assumed-role ARN. ECR push and `ecs update-service` succeed end-to-end.
# Self-check
- [ ] Job grants `id-token: write` (and `contents: read` for checkout).
- [ ] No `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` anywhere — auth is OIDC-only.
- [ ] `configure-aws-credentials@v4` and `amazon-ecr-login@v2` are version-pinned.
- [ ] Trust policy `aud` = `sts.amazonaws.com`; `sub` names exact `repo:OWNER/REPO` + a specific ref/environment (no cross-repo or all-branch wildcard).
- [ ] Permission policy is least-privilege: ECR push actions + `ecs:UpdateService`/`ecs:DescribeServices` only; `ecr:GetAuthorizationToken` the sole `Resource:"*"`.
- [ ] No managed Admin/FullAccess policy attached to the CI role.
- [ ] `AWS_REGION` secret matches the region embedded in the ECR + ECS ARNs.
- [ ] `aws sts get-caller-identity` confirms the assumed-role ARN.
# Common mistakes
- **Forgetting `id-token: write`.** The default `GITHUB_TOKEN` cannot mint an OIDC token without it; role assumption fails with `Could not load credentials from any providers` / `Not authorized to perform sts:AssumeRoleWithWebIdentity`.
- **Loose `sub` condition.** `repo:<org>/*:*` or `repo:<org>/<api-repo>:*` lets any branch (incl. an attacker's PR branch with a malicious workflow) assume the deploy role. Scope to the exact ref or environment.
- **Region mismatch.** `AWS_REGION` differs from where the ECR repo / ECS cluster live → ECR login points at an empty registry or `RepositoryNotFoundException`, ECS update hits `ClusterNotFoundException`.
- **Over-broad permissions.** Attaching `AmazonEC2ContainerRegistryFullAccess` or admin "to make it work" defeats least-privilege and is an audit finding. Scope to the repo/service ARNs.
- **`ecr:GetAuthorizationToken` scoped to a repo ARN.** It does not support resource scoping; if you put it on the repository ARN, ECR login returns `AccessDenied`. It must be `Resource: "*"`.
- **Storing long-lived keys "as a fallback."** Defeats the entire keyless model and creates a leakable credential. Delete any `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` secrets.
- **Adding a new repo by widening an existing role's `sub`.** Prefer a dedicated role (or an explicit additional `StringLike` pattern), not a wildcard that blankets repos.
# Escalation
- **`sts:AssumeRoleWithWebIdentity` denied despite correct YAML**: confirm the account's OIDC provider `token.actions.githubusercontent.com` exists and its thumbprint/audience are current; if the provider is missing, that is an account-admin task — escalate.
- **Need a new AWS account-level OIDC provider**: requires IAM admin; surface to the platform owner with the account ID.
- **Trust-policy change widening who can deploy** (new repo, new environment, new branch): security-sensitive — get explicit sign-off and prefer `security-audit` review before merging.
- **ECS task execution/role separation questions** (the runtime task role vs the CI deploy role): that is application IAM, out of scope here — route to whoever owns the ECS task definitions.
# Examples
<example title="Add a second repo to the OIDC pattern">
Input: "The admin repo needs to deploy to its own ECS service from GitHub Actions."
1. Create a dedicated role `admin-deploy-role` (do NOT widen the API role's `sub`).
2. Trust policy `sub`: `repo:<org>/<admin-repo>:ref:refs/heads/main`, `aud`: `sts.amazonaws.com`.
3. Permission policy: same shape as Step 4, ECR repo ARN `.../admin-service`, ECS service ARN `.../prod-cluster/admin-service`.
4. Set secrets in the admin repo: `AWS_ROLE_ARN` (the new role), `AWS_REGION`, `ECR_REPOSITORY=admin-service`, `ECS_CLUSTER=prod-cluster`, `ECS_SERVICE=admin-service`.
5. Copy the `deploy-prod.yml` auth stanza unchanged; verify with `aws sts get-caller-identity`.
</example>
<example title="Review flagged an over-permissioned CI role">
Finding: the deploy role has `AmazonEC2ContainerRegistryFullAccess` + `AmazonECS_FullAccess` attached.
Fix: detach both managed policies; attach the inline least-privilege policy from Step 4 (ECR push + `ecs:UpdateService`/`ecs:DescribeServices`, scoped to the repo/service ARNs). Re-run the deploy workflow to confirm push + force-deploy still succeed, then confirm `iam:ListAttachedRolePolicies` shows only the scoped inline policy.
</example>
# Failure modes
| Failure | Symptom | Recovery |
|---------|---------|----------|
| Missing `id-token: write` | `Could not load credentials` / `Not authorized to perform sts:AssumeRoleWithWebIdentity` at the configure step | Add `permissions: id-token: write` to the job |
| Trust-policy `sub` mismatch | Role assumption denied even with `id-token: write` | Make `sub` match the actual `repo:OWNER/REPO:ref:...` / `:environment:...` of the running workflow |
| `aud` wrong/missing | Assume denied with condition failure | Set `token.actions.githubusercontent.com:aud` = `sts.amazonaws.com` |
| Region mismatch | ECR login empty / `RepositoryNotFoundException`, ECS `ClusterNotFoundException` | Align `AWS_REGION` with the region in the ECR + ECS ARNs |
| Role lacks ECR perms | `denied: ... is not authorized to perform: ecr:PutImage` on push | Add the Step 4 ECR push actions scoped to the repo ARN |
| `GetAuthorizationToken` mis-scoped | ECR login `AccessDenied` | Move `ecr:GetAuthorizationToken` to its own statement with `Resource: "*"` |
| Role lacks ECS perms | `AccessDenied` on `ecs:UpdateService` | Add `ecs:UpdateService` + `ecs:DescribeServices` scoped to the service ARN |
| OIDC provider absent | Assume fails account-wide for all repos | Create the `token.actions.githubusercontent.com` OIDC provider (IAM admin) |
# Related skills
- `github-actions-cicd` — overall workflow/pipeline structure (triggers, jobs, concurrency, build matrix). Use it for everything around the auth stanza; use this skill for the auth stanza + IAM itself.
- `docker-build` — image build, tag, and `docker push` mechanics after ECR login. This skill stops at "you can authenticate to ECR"; docker-build owns the image.
- `security-audit` — review least-privilege IAM policies and the trust-policy `sub` scoping; pair with it whenever a trust-policy change widens who can deploy.
# How to use
## What it does
This skill sets up and reviews how your GitHub Actions workflows sign in to AWS to push images and trigger deploys. The pattern is keyless: the workflow trades a short-lived OIDC token for temporary credentials by assuming an IAM role, so no long-lived access keys live in repo secrets. You get the workflow auth stanza, the trust policy, and the permission policy as one coherent set.
## When to use it
- A new repo or service needs to push to a container registry and deploy a container service from CI.
- You are writing or auditing the IAM trust policy — the `sub` and `aud` conditions that decide which repo and branch may assume the role.
- You are tightening a CI role that has broad managed policies attached and needs least-privilege scoping instead.
- A deploy fails with `Not authorized to perform sts:AssumeRoleWithWebIdentity`, a registry `denied`, or an `AccessDenied` on the service update.
## When not to use it
- Pipeline structure — job order, triggers, concurrency, build matrix — belongs to `github-actions-cicd`.
- Image build, tag, and push mechanics belong to `docker-build`; this skill stops once you can authenticate.
- Runtime task roles, application IAM, and end-user auth are out of scope; this covers CI identity only.
- Any non-AWS cloud — for another provider's CI auth, use that provider's skill.
## How to invoke
```
Skill(skill: "infra-pack:aws-cicd-auth")
```
Invoke it directly, or just describe the task ("wire OIDC auth for the deploy workflow") and the skill is selected for you.
## Inputs
- Target repo (`OWNER/REPO`) — scopes the trust policy `sub` — required.
- Branch or environment allowed to deploy — required.
- Registry repository name, cluster name, service name — required.
- Region — must match the region in the resource ARNs — required.
- Existing role ARN — only if you are extending a role instead of creating one — optional.
## What you get back
The workflow auth stanza (`permissions:` plus the pinned credentials step), the trust policy JSON, the least-privilege permission policy JSON, the list of secrets to set with their non-secret values, and a verification step whose output proves which role was assumed. Combined policy and YAML output stays under 120 lines — a longer policy is a sign it stopped being least-privilege.
## Worked example
```
Skill(skill: "infra-pack:aws-cicd-auth")
Request: "The admin repo needs to deploy to its own service from GitHub Actions."
You get back:
- a dedicated role, not a widened `sub` on the existing one
- trust policy sub: repo:<org>/<admin-repo>:ref:refs/heads/main, aud: sts.amazonaws.com
- permission policy scoped to that repo's registry ARN and service ARN
- the five secrets to set in the admin repo
- a verification step: aws sts get-caller-identity
```
## Related
- `github-actions-cicd` — reach for it for everything around the auth stanza: triggers, jobs, concurrency.
- `docker-build` — owns the image once you can authenticate to the registry.
- `security-audit` — pair with it whenever a trust-policy change widens who can deploy.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!