Skip to main content

The step that breaks CI/CD when you move to OIDC

· 4 min read
Ashik Mostofa Tonmoy
Senior DevOps Engineer & Platform Engineering Consultant

Long-lived cloud credentials in CI are the most reliably exploited secret in any organisation. They are copied into a settings page once, they never expire, and nothing tells you when one leaks. Federated identity — OIDC — removes them: the pipeline presents a short-lived token that the cloud verifies against your CI provider, and no static key exists to steal.

The migration is genuinely worth doing. It also fails, almost always, at exactly one step.

What is actually happening

Four parties, in order:

  1. Your CI job asks its provider for an identity token.
  2. The CI provider issues a signed JWT describing the job: which repository, which branch, which environment.
  3. Your cloud verifies that signature against the provider's public keys, then checks the claims inside against a trust policy you configured.
  4. If the claims match, the cloud issues short-lived credentials.

Step 4 is where people expect trouble. Step 3 is where trouble actually is.

The step that breaks it

The trust policy matches on the token's subject claim (sub). That claim is a structured string, and its exact shape depends on what triggered the workflow.

For GitHub Actions, the same repository produces different subjects depending on context:

repo:my-org/my-repo:ref:refs/heads/main # push to main
repo:my-org/my-repo:pull_request # pull request
repo:my-org/my-repo:environment:production # job with an environment
repo:my-org/my-repo:ref:refs/tags/v1.2.3 # tag push

Configure the trust policy for ref:refs/heads/main, and everything works. Then someone opens a pull request, and the deploy job fails — because that job's token now says pull_request, which matches nothing in your policy.

The failure arrives as a permission error. That is misleading: permissions are fine, the role was never assumed. You will spend an hour widening IAM policies that were correct from the start.

Print the claim before you debug anything else.

permissions:
id-token: write # without this, no token is issued at all
contents: read

steps:
- name: Show the actual subject claim
run: |
TOKEN=$(curl -sS \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \
| jq -r '.value')
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, aud, repository, ref}'

That prints what your cloud will see. Match the trust policy to that string, not to the one in the documentation example.

Configuring the trust side

AWS, with a condition per context you intend to allow:

{
"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:my-org/my-repo:environment:production"
}
}
}

Azure, as a federated credential on an app registration:

az ad app federated-credential create \
--id "$APP_OBJECT_ID" \
--parameters '{
"name": "github-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/my-repo:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}'

One credential per subject. A pull-request workflow and a production deploy are two different identities, and that is the feature, not an inconvenience — it is what stops a PR from a fork reaching production.

Three more things that will catch you

permissions: id-token: write is required, and its absence is silent. Without it the runner cannot request a token at all. Worse, setting any permissions: block replaces the defaults rather than extending them, so adding id-token: write on its own can quietly remove the contents: read your checkout needed.

Wildcards in sub are a security decision, not a convenience. repo:my-org/my-repo:* will make your pipeline pass. It also means any branch, in that repo, from anyone who can push one, can assume a production role. If you need several contexts, write several credentials.

Forked pull requests do not get a token. By design — a fork could otherwise mint credentials against your cloud. Any workflow that needs cloud access on PRs has to run under pull_request_target, with all the care that carries, or not run on forks at all.

Do it in this order

  1. Add the federated credential for one workflow, in a non-production account.
  2. Run it and print the subject claim. Fix the mismatch.
  3. Add credentials for the remaining contexts, one per context.
  4. Run both auth paths in parallel — OIDC and the old key — for one release cycle.
  5. Delete the static key. If you skip this, you have added a system and removed no risk.

Step 5 is the one that gets postponed indefinitely. The migration only pays for itself when the old credential no longer exists.