Anatomy · CI · 7 min · Sep 8, 2026
The Org Can Assume Prod
You took the access key out of the pipeline. The trust policy still says any repo in the org can become the prod role. That is the same grant with a nicer name.
At a glance
| System | GitHub Actions deploy job that assumes an AWS role |
|---|---|
| Stack | GitHub Actions, OIDC, AWS IAM |
| The miss | The trust policy allows repo:corp/*. Any repo in the org can assume prod. |
The short version: teams stop pasting AKIA into GitHub Secrets and call it done. The deploy role is still one shared principal. The trust policy decides who is allowed to become it. Most of the ones I review say the whole org.
What you are looking at
A deploy job still needs AWS credentials. The safer pattern is OIDC: GitHub mints a short-lived signed token for that one run. AWS checks the signature, then issues temporary credentials for a role. No long-lived key sits in Secrets. That part is right.
Two files make it work. The workflow is how the job knocks. The IAM trust policy is the door. IAM still decides what the role can do once someone is through. This teardown is about the door.
The config
This is a composite, not a dump from one tenant. I keep seeing the same pair: a workflow that assumes a role named prod-deploy, and a trust policy that will hand that role to any repository under the org.
name: deploy
on:
push:
pull_request:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assume prod
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::************:role/prod-deploy
aws-region: us-east-1
- name: Deploy
run: ./scripts/deploy.sh{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::************: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:corp/*"
}
}
}
]
}Three lines that matter
GitHub puts an identity into the token. That field is called sub. The default looks like repo:org/repo:environment:production, or repo:org/repo:ref:refs/heads/main, or repo:org/repo:pull_request. The trust policy matches on sub. Whatever matches can become the role. The role ARN is not a secret. Naming it is a very low bar.
- repo:corp/*. Every repository in the org. A new sandbox, a leftover demo, a contractor experiment, an old workflow nobody owns.
- The attacker is not a stranger on a fork. Fork pull requests do not get an OIDC token. The people who can do this already have write access to some repo in the org — or they compromised an action or dependency that runs in one. That is still a large group.
- The workflow triggers are not the door. Anyone who can push a branch can add their own workflow file with whatever on: they want. on: pull_request is sloppy. It is not what lets them in.
What it actually grants
Whatever prod-deploy can do in AWS. In the rollouts I have reviewed, that is often deploy to the prod account, and sometimes close to admin because "the pipeline has to be able to create resources." The workflow did not create that reach. The role did. The trust policy made it callable from the whole org.
- Write access to any repo in the org — or a compromised action running in one — is enough to assume prod. You do not need to compromise platform.
- The repos and workflows people create outlive them. The engineer who added role-to-assume in March can leave. Nothing removes those repos' ability to assume the role.
Where these files sit
The workflow is not IAM. It lives in the repo and starts the job. The trust policy is IAM. It decides who may assume the role. The role policy is also IAM. It decides what the role can call after it has been assumed. A GitHub Environment is the approval gate in GitHub — reviewers, wait timer, which branch may use it. None of those replace the others.
| Piece | Where it lives | What it decides |
|---|---|---|
| deploy.yml | The repo, under .github/workflows/ | When the job is supposed to run. Hygiene, not a security boundary |
| Trust policy | IAM, on the prod-deploy role | Which GitHub identity is allowed to assume the role |
| Role policy | IAM, on the same role | Which AWS APIs the job can call once it is in |
| GitHub Environment | The repo's Environments settings | Required reviewers, and which branches may use it |
"We use OIDC" names the mechanism. It does not name who is allowed. If sub is repo:corp/*, the long-lived key is gone, but the grant is still a copy: any workflow in the org that names the role can assume it.
The tighter version
Pin sub to one repository and one GitHub Environment. On that Environment, require reviewers and restrict deployment branches to main. The on: filter in the workflow is hygiene. It does not hold the door.
Orange marks what changed from the trust policy above.
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::************: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:corp/platform:environment:production" } } } ] }
Orange marks what changed from the workflow above.
name: deploy on: push: branches: [main] workflow_dispatch: permissions: contents: read jobs: deploy: runs-on: ubuntu-latest environment: production permissions: id-token: write contents: read steps: - name: Checkout uses: actions/checkout@v4 - name: Assume prod uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::************:role/prod-deploy aws-region: us-east-1 - name: Deploy run: ./scripts/deploy.sh
- StringEquals on sub, not StringLike on repo:corp/*. The token must be repo:corp/platform:environment:production. A sandbox repo does not match. A job that forgot environment: production does not match.
- environment: production on the job, with required reviewers on that Environment in GitHub. The trust policy checks the claim. The Environment is what makes a human see the run before it starts.
- Restrict deployment branches to main on that Environment. Without that rule, someone can push a branch whose workflow declares environment: production, and the only thing stopping them is a reviewer clicking approve.
- on: push limited to main is hygiene. Anyone who can push a branch can add a different workflow file. Do not treat the trigger as a security control.
- id-token: write on this job, not the whole workflow, is defense in depth. An attacker with write on the repo can edit the file. The trust policy and the Environment rules are what stop them.
- The role policy is still the blast radius. This door can be tight and the role can still be admin. Do not approve one without the other.
sub is the caller — the repo whose job ran. A reusable workflow from another repo does not change that. AWS now evaluates GitHub claims as their own condition keys, including job_workflow_ref, repository_id, and environment. You still have to evaluate sub; IAM requires it. If you want only a shared deploy workflow to assume the role, pin job_workflow_ref to that file and keep sub scoped to the repos that may call it.
If this org customized the OIDC subject template, or the repo uses immutable subject claims — the default for repositories created after 15 July 2026 — the sub string will not look like the example. Read the token you actually get. Putting repository_id in the template, or as its own condition key, means a renamed or recreated repo does not inherit the grant.
How to tell you are looking at this
- The trust policy has StringLike and repo:org/*, or repo:org/repo:*, or no sub condition at all.
- You cannot name which repository is allowed to assume prod-deploy, or who is allowed to add a new one.
- More than one repo in the org already copies the same role-to-assume ARN. The ARN is not a secret.
- The GitHub Environment is missing, has no required reviewers, or does not restrict deployment branches to main.
The takeaway
Do not approve the pipeline until you can approve the trust policy. OIDC removed the key. It did not name who is allowed. repo:corp/* means the org can assume prod. Start there.
- The Key in the Env Block: The same miss one step earlier: a long-lived key in the launch file.
- No Last Day: The workflow and the trust condition both skip offboarding.
- The Deny Is the Perimeter: A tight door still sits under whatever the role — and the SCP — allow.