---
title: "The Pipeline Has No Secret. It Still Has a Cloud Identity."
description: "Replacing static deployment keys with OIDC removes a credential from the repository. It does not decide which workflow deserves the resulting cloud role — that decision lives in the trust policy, and one wildcard can turn an organisation into a deployment API."
date: 2026-04-16
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/ci-oidc-trust-boundary/
---

## CI/CD OIDC trust in 60 seconds

The best CI/CD security change most teams make is deleting the long-lived cloud key from
their repository and replacing it with OpenID Connect. The workflow requests a short-lived
identity token, the cloud exchanges it for temporary credentials, and there is no secret to
rotate, leak, or find in an old build log.

That is a real improvement. It also creates a new failure mode that looks clean in every
secret scanner: the credential is gone, but the *right to mint one* is broader than anyone
realised.

OIDC does not remove trust from the pipeline. It moves trust into claims and policy
conditions. If those conditions say "any workflow in this organisation" when the intended
meaning was "the reviewed deployment job on the protected production branch," the pipeline
has become a cloud identity provider with an organisation-wide enrolment policy.

## The secret was never the authorisation boundary

A static access key collapses authentication and authorisation into one string: whoever has
the value can use the principal. OIDC separates them. GitHub authenticates the workflow and
issues a signed token; AWS decides whether the claims in that token are allowed to assume a
specific role.

The second decision is the load-bearing one.

For GitHub Actions, the AWS trust policy normally evaluates two claims:

- `aud` identifies the intended token exchange, commonly `sts.amazonaws.com` when using the
  official AWS credentials action.
- `sub` identifies the repository context that requested the token: repository, branch,
  tag, pull request, or environment, depending on the job.

The role's permission policy answers *what the session may do*. Its trust policy answers
*which workflow may become that session*. A least-privilege permission policy attached to a
production role does not compensate for a trust policy that lets the wrong repository assume
it. Least privilege begins one document earlier than most reviews start.

<figure class="diagram">
<svg viewBox="0 0 700 286" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="GitHub Actions OIDC trust path. A workflow receives a signed OIDC token containing audience and subject claims. AWS STS evaluates both claims against the role trust policy before issuing a temporary role session. A broad subject wildcard expands the trusted source, while an exact repository and protected environment condition keeps the boundary narrow.">
<defs>
<marker id="oidc-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
</defs>
<text x="0" y="18" class="dg-muted">AUTHENTICATED WORKFLOW → CLAIMS → TRUST DECISION → CLOUD SESSION</text>
<rect x="0" y="34" width="144" height="68" rx="8" class="dg-box" />
<text x="14" y="56" class="dg-label">workflow run</text>
<text x="14" y="76" class="dg-mono">id-token: write</text>
<text x="14" y="92" class="dg-muted">may request JWT</text>
<line x1="146" y1="68" x2="180" y2="68" class="dg-line-accent" marker-end="url(#oidc-a)" />
<rect x="184" y="34" width="154" height="68" rx="8" class="dg-box" />
<text x="198" y="56" class="dg-label">GitHub OIDC</text>
<text x="198" y="76" class="dg-mono">aud + sub + ref</text>
<text x="198" y="92" class="dg-muted">signed claims</text>
<line x1="340" y1="68" x2="374" y2="68" class="dg-line-accent" marker-end="url(#oidc-a)" />
<rect x="378" y="34" width="154" height="68" rx="8" class="dg-box-accent" />
<text x="392" y="56" class="dg-label">role trust</text>
<text x="392" y="76" class="dg-accent">CONDITIONS</text>
<text x="392" y="92" class="dg-muted">actual boundary</text>
<line x1="534" y1="68" x2="568" y2="68" class="dg-line-accent" marker-end="url(#oidc-a)" />
<rect x="572" y="34" width="128" height="68" rx="8" class="dg-box-accent" />
<text x="586" y="56" class="dg-label">STS session</text>
<text x="586" y="76" class="dg-mono">short-lived</text>
<text x="586" y="92" class="dg-crit">CLOUD IDENTITY</text>
<line x1="0" y1="130" x2="700" y2="130" class="dg-line dg-dash" />
<text x="0" y="156" class="dg-muted">THE SAME TOKEN ISSUER — TWO VERY DIFFERENT TRUST SURFACES</text>
<rect x="0" y="172" width="336" height="76" rx="8" class="dg-box" />
<text x="14" y="194" class="dg-crit">repo:ORG/*</text>
<text x="14" y="214" class="dg-mono dg-compact">any matching repository may become the role</text>
<text x="14" y="232" class="dg-muted">large, changing identity population</text>
<rect x="364" y="172" width="336" height="76" rx="8" class="dg-box-accent" />
<text x="378" y="194" class="dg-accent">repo + protected environment</text>
<text x="378" y="214" class="dg-mono dg-compact">named source, reviewed deployment boundary</text>
<text x="378" y="232" class="dg-muted">small, intentional identity population</text>
</svg>
<figcaption>The OIDC provider proves where a token came from. The role trust policy decides whether that source is entitled to become a cloud identity. Short-lived credentials reduce shelf life; precise claim conditions reduce who can mint them.</figcaption>
</figure>

## One wildcard changes the unit of compromise

AWS refuses a new GitHub trust policy whose `sub` condition is absent, null, or only a bare
wildcard. That guardrail is useful, but it does not make every accepted pattern narrow. This
condition is syntactically constrained and operationally enormous:

```json
{
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
  },
  "StringLike": {
    "token.actions.githubusercontent.com:sub": "repo:example-org/*"
  }
}
```

It means the security boundary is no longer one deployment repository. It is every current
and future repository whose subject matches that organisation pattern, plus every actor and
workflow path that can cause one of those repositories to request a token.

The mistake is easy to make because the policy reads like central governance: "our GitHub
organisation may deploy." But organisations contain archived projects, migration tooling,
internal experiments, forks, and repositories with very different branch protections. A
single low-sensitivity repository becomes relevant to production the moment its workflow
can satisfy the same `sub` pattern as the deployment repository.

The reliable boundary is the smallest stable claim set the provider and cloud can both
enforce: exact repository identity, the expected audience, and either a protected branch or
a protected GitHub environment. When an environment appears in the job, the default subject
shape changes to include that environment rather than the branch. That is not cosmetic; the
trust policy and the repository's environment protection rules now form one control.

## Test the issuer's vocabulary before writing policy

Do not begin by copying a trust-policy example. Begin by observing the claims produced by
each workflow context you intend to distinguish: production environment, main branch,
pull-request job, tag release, and reusable workflow.

In an authorised test repository, request the token only long enough to decode its header
and claims locally. Do not log the encoded token or exchange it for a production role.

```yaml
permissions:
  contents: read
  id-token: write

jobs:
  inspect-claims:
    runs-on: ubuntu-latest
    environment: oidc-lab
    steps:
      - uses: actions/github-script@v8
        with:
          script: |
            const token = await core.getIDToken('sts.amazonaws.com')
            const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url'))
            core.info(JSON.stringify({ aud: claims.aud, sub: claims.sub, ref: claims.ref }))
```

Use a non-privileged lab role whose permission policy allows only `sts:GetCallerIdentity`.
The positive case should assume it from the intended environment. The negative cases should
fail from a feature branch, a pull request, a second repository, and a job that does not
reference the protected environment. If one of those succeeds, the trust policy is broader
than its author thinks.

Do not print the full token. It is a bearer credential for its short lifetime, and a log is
still a disclosure even when the value expires quickly.

## Names are not always the most durable identity

Repository names made OIDC policy readable, but names can be renamed, transferred, and in
some systems reused. GitHub's current OIDC reference documents an immutable subject format
for newer or opted-in repositories that includes owner and repository IDs. Older repositories
may still emit the previous name-based form until migrated.

That transition creates a review question most examples omit: *which subject format does
this repository actually issue today?* A policy written for the new shape will deny an old
repository. A migration performed without updating the cloud trust will break deployment. A
fallback wildcard added during the outage can quietly preserve both formats forever and
leave the role broader than before.

Treat subject-format migration like an identity migration: capture the current claims,
stage an exact new condition, run positive and negative controls, then remove the old form.
Compatibility should be a measured window, not a permanent wildcard.

## Evidence matrix

| Signal | What it proves | Negative control | Defender verification |
| --- | --- | --- | --- |
| Observed `aud` and `sub` from the intended protected job | The issuer vocabulary and exact deployment identity are known | Collect claims from a pull request, feature branch, and second repository; each should differ in the expected field | Compare live claims with the trust-policy operators and values |
| Lab role assumption succeeds only from the approved environment | The claim conditions enforce the intended source boundary | Attempt the same role from every near-neighbour context and require denial | Correlate `AssumeRoleWithWebIdentity` events with repository, subject, role, and session name |
| Organisation or repository wildcard matches more identities than documented | The unit of compromise is larger than the deployment repository | Replace it with exact conditions and confirm legitimate deployment still works | Enumerate every repository and workflow that can satisfy each wildcard pattern |
| Environment protection blocks an unreviewed deployment job | Repository controls and cloud trust compose into one boundary | Remove reviewer/branch eligibility in the lab and confirm the token can no longer satisfy the production path | Review environment reviewers, deployment branches, administrators, and bypass permissions |
| Immutable subject migration accepts the new form and denies the retired form | Compatibility did not leave a second permanent trust route | Present a token using the old subject shape after the cutover and require denial | Track subject format, repository ID, owner ID, and policy migration date |

## The pattern I keep seeing

The team celebrates deleting the cloud secret, closes the credential-rotation ticket, and
never assigns ownership of the trust policy that replaced it. Platform engineering owns the
workflow. Cloud security owns IAM. Repository administrators own environments. The identity
exists only when all three systems agree, so no single owner sees the complete enrolment
rule.

That is why the durable finding is not "OIDC is misconfigured." It is: "this production role
accepts tokens from these additional workflow contexts, and this one condition is where the
boundary expands." The path should be as concrete as any other identity escalation path.

## What to hand the defenders

**The resolved trust population.** Translate each `StringLike` pattern into the repositories,
branches, tags, pull-request jobs, and environments it actually matches today.

**A claim contract.** Record the expected issuer, audience, subject format, repository
identity, and deployment context. Test that contract after repository transfers, environment
changes, or subject-format migrations.

**The negative cases.** A successful production deployment proves availability. It does not
prove exclusivity. Keep automated denial tests for the nearest untrusted contexts, because
those are what detect a trust policy becoming broad again.

The pipeline no longer stores a cloud credential. It stores something more important: the
policy that decides who may ask for one.