Module 11 — Secrets Handling in Pipelines (OIDC)¶
Type 7 · Build-&-Operate — refactor a deploy pipeline from a stored long-lived key to OIDC federation, then prove no static secret remains and the minted credential is short-lived; the deliverable is the working federated pipeline and its verification, not an essay. (Secondary: Judgment-as-Code — the IAM trust policy whose sub scope is the real control.) Go to the hands-on lab →
Last reviewed: 2026-06
Security Automation — the most secure secret in a pipeline is the one that doesn't exist — mint a short-lived credential at deploy time instead of storing a key you'll spend the rest of the year hoping nobody leaks.
In 60 seconds
The most secure secret in a pipeline is the one that doesn't exist. A stored long-lived cloud key is
standing, ambient authority — no expiry, copied into every fork and log line, the wound every
secret-scanner keeps dressing (Codecov, CircleCI, the endless "exposed CI token" write-ups).
OIDC federation dissolves it: the runner mints a signed per-run JWT, hands it to AWS STS via
AssumeRoleWithWebIdentity, and gets back credentials that are already expiring — nothing stored.
The security lives in the IAM trust policy's sub condition: pinned to the exact repo/ref it
admits only your pipeline; loose (repo:org/*:*) it's a wildcard grant any fork can mint.
Why this matters¶
This track has taught you to hunt leaked secrets thoroughly — gitleaks in Module 05, trufflehog
in Module 06, keeping credentials out of state files and git in Module 02. But every one of those is
damage control on a system that still stores a long-lived cloud key somewhere a pipeline can read it.
That stored key is the wound the scanners keep dressing: the AWS access key sitting in a CI secret, the
deploy credential pasted into a variable, the service-account JSON baked into a runner. It never rotates
on its own, it's copied into every fork and every log line that forgets to mask it, and the breach
write-ups are monotonous — the Codecov supply-chain compromise, the CircleCI January-2023 incident, the
endless "exposed CI token" disclosures all turn on a credential that existed long enough to be stolen.
The build-side fix is not "scan harder." It's to make the pipeline mint a short-lived, federated
credential at runtime so there is no static secret to leak in the first place. This is the single most
prevalent real-world CI security control the track hasn't had you build — and it's the mirror image of
all the leak-detection you already know.
Objective¶
Refactor a deployment pipeline from a stored long-lived cloud key to OIDC federation: the runner
presents a signed, per-run identity token to AWS STS via AssumeRoleWithWebIdentity and receives
short-lived credentials scoped by an IAM trust policy to exactly this repo and branch. Then prove
it: confirm the pipeline holds no long-lived AWS key anywhere, and that the credentials it minted are
genuinely short-lived (a session token is present and an expiry is set) and scoped (the trust
policy admits only the intended subject/audience). The proof is the deliverable — anyone can claim "we use
OIDC"; you show the static key is gone and the minted credential expires.
The core idea¶
Start with what a stored credential actually is: standing, ambient authority. A long-lived AWS access
key in a CI secret is a bearer token with no expiry and no context — whoever holds the two strings is
that IAM principal, from anywhere, until a human remembers to rotate it. That's the entire leak surface.
It has to be written down (in a secret store, an env var, a .tfvars), it gets copied wherever the
pipeline runs, and it survives every incident because nothing forces it to age out. Secret-scanning
exists because this pattern exists. The move that dissolves the problem is to stop storing the key and
instead prove who you are at the moment you need access, and get a credential that's already expiring.
The mental model
Stop storing a secret; instead prove who you are at the moment you need access and receive a
credential that's already on a timer. The control plane moves from "protect and rotate a secret"
(a race you often lose) to "govern who is allowed to federate" — one declarative IAM document, with
every AssumeRoleWithWebIdentity call landing in CloudTrail as an audit trail the static key never
produced.
OIDC web-identity federation is how a pipeline does that. The CI platform (GitHub Actions, GitLab,
Buildkite) runs its own OpenID Connect identity provider: at job time it mints a short-lived JWT that
describes this specific run — signed by the platform's private key, carrying claims like
iss (who issued it), aud (who it's for), and sub (the exact repo + branch + environment, e.g.
repo:acme-corp/api:ref:refs/heads/main). The job hands that JWT to AWS STS via
AssumeRoleWithWebIdentity. STS fetches the provider's public keys from its published JWKS endpoint,
verifies the signature (so the token can't be forged), checks the claims against the role's trust
policy, and — if it all matches — returns temporary credentials: an access key, a secret, and a
session token, stamped with an expiry minutes to an hour out. No secret was stored anywhere. The runner
arrived with nothing and left with a credential that's already on a timer.
sequenceDiagram
participant R as Runner (CI job)
participant I as CI OIDC provider
participant S as AWS STS
R->>I: request per-run token
I-->>R: signed JWT (iss/aud/sub)
R->>S: AssumeRoleWithWebIdentity(JWT)
S->>I: fetch JWKS, verify signature
S->>S: check sub/aud vs trust policy
S-->>R: temp creds + SessionToken + Expiration
Note over R,S: nothing stored — credential already expiring
The piece that carries the security is the trust policy, and this is where the judgment lives. Setting
up the IAM OIDC provider establishes that you trust GitHub's issuer; the trust policy on the role decides
which workflows may assume it, by asserting conditions on the JWT's claims — aud must equal
sts.amazonaws.com, and sub (via StringEquals or a carefully bounded StringLike) must match the
exact repo and ref. Get this wrong and you've rebuilt the very problem you were escaping: a trust policy
that matches repo:acme-corp/*:* lets any branch of any fork mint your production credential — the
OIDC equivalent of a wildcard IAM grant, and a documented real-world misconfiguration. A sub pinned to
repo:acme-corp/api:ref:refs/heads/main (or to a protected GitHub environment) admits only the pipeline
you meant. The trust policy is the scope, and a loose condition is worth no more than the static key you
removed.
The gotcha
The trust policy is the whole control, and a loose sub quietly rebuilds the problem you escaped: a
condition matching repo:acme-corp/*:* lets any branch of any fork mint your production
credential — the OIDC equivalent of a wildcard IAM grant, and a documented, exploited
misconfiguration. Pin aud = sts.amazonaws.com and a sub to the exact repo/ref (or a protected
environment). A loose condition is worth no more than the static key you just removed.
Which reframes where the off-switch is. With a stored key, revocation means a human finding the secret in
every store and rotating it before the attacker uses it — a race you often lose. With OIDC, there is no
secret to find: you edit the trust policy (tighten the sub, remove the provider, narrow the role's
permissions) and the next run simply can't assume the role. The control plane moved from "protect and
rotate a secret" to "govern who is allowed to federate" — declarative, in one IAM document, with every
AssumeRoleWithWebIdentity call landing in CloudTrail as an audit trail the static key never produced.
The same short-lived-token-per-request principle is exactly what Module 02's user OIDC and the ZTNA track's
workload SVIDs do; here it's the pipeline that holds no standing secret.
AI caveat
A model writes the plumbing fluently — the configure-aws-credentials workflow, the
create-open-id-connect-provider call, the AssumeRoleWithWebIdentity request. Where you own the
judgment is the trust policy's sub condition: the model has no way to know which repo and
branch should federate, so it cheerfully emits a StringLike on repo:org/*:* — broad enough to
"just work," and broad enough to let any fork mint your production credential. Review every condition
against "could a workflow I didn't intend satisfy this sub?"
Learn (~3 hrs)¶
The pattern: OIDC in CI (~1.5 hrs)
- GitHub — About security hardening with OpenID Connect — the authoritative "why no long-lived secret" framing and how the runner mints a per-job token. Read "Overview of OpenID Connect" and the JWT-claims section (sub, aud, iss) — those claims are exactly what your trust policy scopes to.
- GitHub — Configuring OpenID Connect in Amazon Web Services — the end-to-end GitHub→AWS recipe the lab mirrors: add the IAM OIDC provider, write the role's trust policy on token.actions.githubusercontent.com, and the id-token: write + configure-aws-credentials workflow. This is the build you're reproducing locally.
The AWS side: AssumeRoleWithWebIdentity & the trust policy (~1 hr)
- AWS IAM — Create an OpenID Connect (OIDC) identity provider in IAM — what an IAM OIDC provider is (issuer URL + audience + thumbprint) and how a role's trust policy then conditions on the token's claims. Read "Creating OIDC identity providers" and the trust-policy condition example — the StringEquals on :sub is the scoping control.
- AWS STS — AssumeRoleWithWebIdentity (API reference) — the call itself: what you pass (RoleArn, WebIdentityToken) and what comes back. Read the Response Elements — Credentials carries AccessKeyId, SecretAccessKey, SessionToken, and Expiration. That SessionToken + Expiration is precisely what the lab's check asserts to prove the credential is short-lived.
Why this is the standard, and the failure modes (~0.5 hr)
- aws-actions/configure-aws-credentials — README — the action that does the federation in real pipelines; read the "OIDC" / "Assuming a role" section and the security note on not using long-lived keys. Reference for the Automate & own it build.
- Datadog Security Labs — How attackers exploit GitHub Actions OIDC misconfigurations (the loose sub) — concrete write-up of the over-broad trust-policy condition that lets a fork or unintended branch assume the role; read for the exact sub-wildcard mistake your trust policy must avoid. (If unreachable, the GitHub doc's "Configuring the subject in your cloud provider" section covers the same scoping mistake.)
Key concepts¶
- A stored long-lived key is standing, ambient authority — no expiry, copied everywhere; it is the leak surface that secret-scanning exists to manage.
- OIDC federation mints a per-run signed JWT (
iss/aud/sub) that describes the job; STS verifies it against the provider's JWKS and returns short-lived credentials — no secret stored. AssumeRoleWithWebIdentityreturns temporary creds with aSessionTokenandExpiration— the proof a credential is short-lived, not standing.- The trust policy is the scope:
aud = sts.amazonaws.comand asubpinned to the exact repo/ref (notrepo:org/*:*). A loosesubis a wildcard grant — a real, exploited misconfiguration. - The off-switch moved from "find and rotate the secret" to "edit the trust policy / remove the provider" — declarative, one document, audited in CloudTrail.
- Same principle as user OIDC (Module 02) and workload SVIDs (ZTNA) — short-lived, verifiable identity per request, no standing secret — applied to the pipeline.
AI acceleration¶
A model writes the plumbing here fluently: the configure-aws-credentials workflow with
permissions: id-token: write, the aws iam create-open-id-connect-provider call, the
AssumeRoleWithWebIdentity request. Where you own the judgment is the trust policy's sub
condition, because that is the actual security control and the model has no way to know which repo and
branch should be allowed to federate. Ask it for a GitHub→AWS trust policy and it will happily emit a
StringLike on repo:org/*:* — broad enough to "just work," and broad enough to let any fork mint your
production credential. Review every condition against "could a workflow I didn't intend satisfy this
sub?", pin it to the exact ref or a protected environment, and prove it the way the lab does:
confirm no static key remains and that the minted credential carries a session token and a future expiry.
AI drafts the federation; you scope the trust and verify the secret is genuinely gone.
Check yourself
- Why is a stored long-lived access key "standing, ambient authority," and what makes OIDC's minted credential fundamentally different?
- A trust policy uses
StringLikeonrepo:acme-corp/*:*. Concretely, who can now mint your production credential, and what's the fix? - "We use OIDC" isn't proof. What two observable properties of the minted credential do you check to actually show the static key is gone?
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).