Lab 11 — Secrets Handling in Pipelines: OIDC & Short-Lived Credentials¶
Hands-on lab · ← Back to the module concept
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo. It uses
LocalStack to simulate AWS STS + IAM locally — no cloud account or
real credentials required. A "runner" container plays the role of a CI job; it mints a short-lived,
signed OIDC-style JWT and exchanges it at STS for temporary credentials.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/automation/11-pipeline-secrets
make up # start LocalStack, register the IAM OIDC provider + a scoped deploy role
make bad # the BEFORE: deploy with a stored long-lived key (the leak surface)
make oidc # the AFTER: federate via AssumeRoleWithWebIdentity for short-lived creds
make check # PROVE it: no static secret, credential short-lived + trust policy scoped
make demo # the full before -> after -> proof walkthrough
make shell # drop into the runner to work by hand
make down # stop when done
make up registers an IAM OIDC identity provider for the lab's local issuer and creates
DeployRole, whose trust policy (data/trust-policy.json) is scoped to exactly one
subject (repo:acme-corp/api:ref:refs/heads/main) and audience (sts.amazonaws.com). The runner
container ships awslocal (a drop-in for aws pointed at LocalStack), python3, and the
mint_oidc_token.py / check_no_static_secret.py tooling.
Everything runs locally against a simulated AWS environment you own. No real cloud credentials.
First run note / pending validation: this environment is new and has not yet been validated with
make up/make demoon a Linux Docker host (it was authored in a session without Docker). The moving parts most likely to need a tweak on first run are: (1) LocalStack community is lenient about OIDC — it returns correctly-shaped temporary credentials fromAssumeRoleWithWebIdentitybut does not strictly fetch the JWKS and cryptographically verify the JWT signature, nor strictly enforce the trust-policysub/audconditions, the way real AWS STS does. So the lab teaches the real-world pattern (a signed JWT, a JWKS, a scoped trust policy) while the lab's proof asserts what LocalStack faithfully reproduces: no stored static key, a returned SessionToken + future Expiration (short-lived), and a statically-analysable scoped trust policy. (2) The IAMcreate-open-id-connect-providerthumbprint is a placeholder — fine against LocalStack; real AWS validates it. Ifmake demosnags, those two are where to look — seedata/setup.shanddata/pipeline-oidc.sh.
Scenario¶
The target org's api repo deploys to AWS from GitHub Actions. The deploy job authenticates with
a long-lived IAM access key stored as a repo secret — the same standing-credential shape behind the
Codecov (2021) and CircleCI (Jan 2023) incidents, where a static token lifted from CI was
abused before anyone rotated it.
Security's mandate: no long-lived cloud credential may be stored in CI. Your job is to refactor
the deploy pipeline from the stored static key to OIDC federation — the runner mints a per-run
identity token and trades it at AWS STS for a short-lived credential — and then to prove the
static secret is gone and the minted credential is genuinely short-lived and scoped to this pipeline.
Do¶
Part 1: See the leak surface (the BEFORE)¶
- [ ] Run the static-key pipeline.
make bad. Readdata/pipeline-bad.sh: the runner is handed a long-livedAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY— two strings that are a standing IAM principal with no expiry. Note what the credential lacks: there is no session token and no expiration. Where would this key have to live in a real pipeline, and which of this track's earlier tools (Module 05, Module 06) exist specifically to catch it leaking?
Part 2: Build the OIDC pipeline (the AFTER)¶
-
[ ] Read what
make uptrusted. Inspect the registered identity provider and role:make shell, thenawslocal iam list-open-id-connect-providersandawslocal iam get-role --role-name DeployRole. The role's trust policy (data/trust-policy.json) is the security control — find theaudandsubconditions and say, in one sentence, which exact workflow is allowed to assume this role. -
[ ] Mint a token and federate.
make oidc(or rundata/pipeline-oidc.shfrom the shell). Watch the three steps: a short-lived RS256 JWT is minted describing this run (iss/aud/sub), it's handed toaws sts assume-role-with-web-identity, and STS returns temporary credentials. Confirm the response carries aSessionTokenand anExpiration. The runner started with no stored AWS key — where did the credential it deploys with come from, and when does it stop working? -
[ ] Refactor the real workflow. Compare
data/deploy-static-key.yml(the before) withdata/deploy-oidc.yml(the after). Note the three changes that are the refactor:permissions: id-token: write,role-to-assume:instead ofaws-access-key-id/aws-secret-access-key, and the role's trust policy doing the scoping. This is the exact GitHub→AWS pattern from the Learn path; the local lab is its mechanics made legible.
Part 3: Prove it¶
- [ ] Run the proof.
make check. All assertions must PASS: - no stored static key — the OIDC pipeline carries no
AKIA…key id oraws-secret-access-keyinput, and the workflow federates (role-to-assume+id-token: write); - short-lived credential — the credential minted in step 3 has a
SessionTokenand anExpirationin the future; -
scoped trust policy —
aud = sts.amazonaws.comandsubpinned to the exact repo/ref (StringEquals, no wildcard). -
[ ] Prove the deny / the wildcard mistake.
make check-looseruns the same checker againstdata/trust-policy-loose.json, whosesubisrepo:acme-corp/*:*. Watch assertion 3 FAIL: that wildcard would let any fork or branch of the org assume the production deploy role — the OIDC equivalent of a wildcard IAM grant, and a documented real-world misconfiguration. In your write-up, explain why a passingmake oidcwith this loose policy is worse than the static key you removed, and what the correctsubadmits.
Success criteria — you're done when¶
- [ ]
make oidccompletes anAssumeRoleWithWebIdentityand the minted credential carries aSessionTokenand a futureExpiration. - [ ]
make checkexits 0 — no stored static key in the OIDC pipeline, the credential is short-lived, and the trust policy is scoped to the exact repo/ref. - [ ]
make check-looseshows the scoped-trust assertion FAIL on the wildcardsub— you can explain why that is the wildcard-IAM failure mode for OIDC. - [ ]
findings.mdrecords the before/after, the short-lived-credential proof, and the loose-vs-scopedsubreasoning.
Deliverables¶
findings.md — the pipeline-secrets write-up: the static-key leak surface, the OIDC refactor (the
three workflow changes), the short-lived-credential proof (SessionToken + Expiration), and the
scoped-vs-wildcard sub analysis. deploy-oidc.yml — your refactored workflow (or a copy of the
reference) that federates with no stored key. Commit both. Never commit the OIDC signing key
(data/oidc/private.pem), the minted token, role-arn.txt, or any credential JSON — they're in
.gitignore.
Automate & own it¶
Required. Write verify_pipeline.py (or extend check_no_static_secret.py) that a CI job can run
as a gate: given a workflow file and (optionally) a minted-credentials file, it exits non-zero if
the workflow stores a long-lived key, if the assumed credential lacks a SessionToken/Expiration,
or if the role trust policy's sub contains a wildcard. Have a model draft the JWT-decode and the
trust-policy walk; you verify the wildcard-sub case is actually caught (run it against
trust-policy-loose.json and confirm it fails) — a gate that passes a repo:org/*:* policy is worse
than no gate. Commit it as the reusable "no static secret in CI" check you'd drop into any repo.
AI acceleration¶
A model writes the configure-aws-credentials OIDC workflow, the
aws iam create-open-id-connect-provider call, and the assume-role-with-web-identity invocation
fluently. Where you own the judgment is the trust policy's sub condition — ask 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 with make check / make check-loose before trusting it.
Connects forward¶
This is the build-side mirror of the leak-detection the track already teaches — gitleaks
(Module 05) and trufflehog (Module 06) hunt the stored key; here you remove the key so there is
nothing to hunt. The short-lived-token-per-request principle is the same one as user OIDC in the
ZTNA track and the workload SVIDs in Workload Identity & mTLS — verifiable identity, no
standing secret, automatic expiry — applied to the pipeline. In the track capstone, the rubric's
"secrets handled out of band" line is exactly this: the pipeline that deploys without a stored
credential.
Marketable proof¶
"I refactor CI pipelines from stored long-lived cloud keys to OIDC federation — the runner mints a per-run identity token and trades it at STS for short-lived, scoped credentials — and I prove the static secret is gone, the credential expires on its own, and the role's trust policy admits only the intended repo and branch, not any fork."
Stretch¶
- Tighten to a protected environment. Change the trust policy
subto a GitHub environment form (repo:acme-corp/api:environment:production) and explain how requiring a protected environment (with required reviewers) adds a human gate on top of the cryptographic scoping. - Add a deploy gate. Wire
verify_pipeline.pyintodata/deploy-oidc.ymlas a job step that fails the build if a static key reappears or the trust policy goes wildcard — the OIDC analogue of the secret-scanning gate from Module 05. - Compare providers. Sketch the GitLab CI (
CI_JOB_JWT_V2/id_tokens) and Buildkite OIDC equivalents — sameAssumeRoleWithWebIdentityflow, different issuer andsubclaim shape — and note what changes in the trust policy for each.
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).