DevSecOps in Depth · Zero Trust for Pipelines

Zero Trust for Pipelines

Every other page in this course's Pipeline Security group quietly assumes the CI system itself can be trusted — it's where SAST and SCA run, where the container gets built, where the deploy actually happens. This page questions that assumption directly, by applying NIST SP 800-207's zero-trust architecture specifically to a build system: no long-lived cloud credential sitting in a CI secret store, a short-lived token minted fresh for each individual job through OIDC federation, and the runner itself treated as untrusted infrastructure the moment it boots, not as an extension of your own trusted network. That last shift — from "our CI is inside our perimeter, so we trust it" to "our CI is compute we happen to rent, so we verify it like everything else" — isn't theoretical. It's what a wave of 2023 CI/CD supply-chain compromises made non-negotiable.

☺ Explain it like I'm 10

Imagine a hotel that hands every housekeeper a single master key on a lanyard — one key that opens every room, forever, until someone finally remembers to change the locks, which is rare. If that key gets copied while it's hanging off a cart in a hallway, whoever has the copy can now walk into any room, any time, for as long as nobody notices — often for a very long time. A zero-trust hotel does something different: the front desk issues a fresh keycard right before each shift, programmed to open only the three rooms that housekeeper is cleaning today, and it stops working the second the shift ends. And the front desk doesn't wave the cart through a door just because it recognizes the cart from yesterday — every door checks the card, every single time, even the tenth time that day. Zero trust for a build pipeline is exactly this: no master key sitting in a drawer, a fresh key minted per job that only opens what that job needs, and a door that verifies the key every time instead of remembering the cart.

🐘🐢Your hosts for this topic: Ellie the Elephant & Timmy the Turtle — Ellie already refuses to set a secret down anywhere it could be found; this page is what happens when you take that instinct all the way and stop keeping standing credentials around at all. Timmy still won't let anything past the gate un-scanned — here the gate stops trusting the machine standing at it, too.

What NIST SP 800-207 actually asks of a build system

☺ Like you're 10: "Zero trust" doesn't mean nobody's ever trusted — it means nobody's trusted automatically, just for being on the inside.

NIST SP 800-207, published in 2020, is the reference document most vendors gesture at when they sell you "zero trust," and it's worth reading past the marketing to the actual tenets, because they translate onto a build pipeline almost without modification. Paraphrased and mapped onto CI/CD specifically (check the source document directly for NIST's exact wording — this page condenses seven tenets into the ones that matter most here):

Read that last point again with a CI runner specifically in mind, because it's the one most teams skip. A build server is compute you don't watch continuously, running third-party code (dependencies, base images, and — increasingly — third-party CI actions) that you didn't write, on a schedule an attacker can trigger just by opening a pull request. Under 800-207's own "assume breach" framing, a runner isn't a trusted internal asset that happens to run untrusted code. It's untrusted compute, full stop — and every control in this page follows from taking that sentence literally instead of treating it as a rhetorical flourish.

The old model: a credential that outlives every job it was ever used in

☺ Like you're 10: A password taped to the inside of a drawer isn't hidden — it's just waiting for whoever opens the drawer next.

For most of CI/CD's history, a pipeline authenticated to a cloud account the same way a person does: a static access key — an AWS IAM user's access key ID and secret access key, a GCP service-account JSON key file, an Azure client secret — pasted once into the CI platform's secret store and referenced by every job, every branch, indefinitely. Nothing about that credential expires on its own. Nothing about it is scoped to the one job that happened to use it this run. It sits there until a human remembers to rotate it, which — across a large enough fleet of pipelines — is functionally "rarely."

That's an implicit trust root in the exact shape NIST SP 800-207 warns about: possession of the secret is the authorization check, permanently, regardless of who or what currently holds it. Compromise the CI platform's secret store, or the runner process that has that secret injected into its environment for the duration of a job, and you inherit whatever that credential can do — for as long as it stays valid, which in practice is usually until an incident forces a rotation. This isn't a hypothetical failure mode. It's the specific mechanism behind a run of well-documented 2023-era CI/CD compromises, and it's worth being precise about what happened in each one rather than treating "supply chain attack" as one undifferentiated category:

Neither incident required the attacker to find a vulnerability in the application being built. Both required, at some point, treating a build system's own standing access as trustworthy simply because of where it sat. The SolarWinds case study elsewhere in this course covers the 2020 predecessor to this pattern in full; this page is about the specific, mechanical fix — removing the standing credential entirely — rather than the incident narrative.

⚠ Watch out

The instinct after an incident like this is usually "rotate the key and scope it more tightly." That's necessary, but it doesn't fix the underlying model — it's still a static secret sitting somewhere, still valid until the next rotation, still a single artifact that grants standing access the moment someone reads it. Tighter scoping shrinks the blast radius of the old model. It doesn't remove the model. The rest of this page is about removing it.

OIDC federation: trading identity for a credential, never storing one

☺ Like you're 10: Instead of carrying a key that opens the door forever, you show ID at the door and get handed a key that only works for the next few minutes.

OpenID Connect (OIDC) federation is how a modern pipeline satisfies the "short-lived, per-session" tenet without anyone having to remember to rotate anything, because there's nothing standing to rotate. The mechanism, concretely: most CI platforms — GitHub Actions, GitLab CI, CircleCI, Buildkite — can act as an OIDC identity provider for their own jobs. When a job requests one, the platform mints a signed JSON Web Token (JWT) that names, as claims baked into the token itself, exactly which job this is: the repository, the branch or tag, the workflow file, and — if the job targets one — the named deployment environment. That token is short-lived and single-purpose; it's not a credential for any cloud resource by itself, it's a notarized statement of identity.

The job presents that JWT to the cloud provider's identity broker — AWS STS, GCP's Workload Identity Federation, Azure's federated credentials on an app registration. The broker does two checks: it verifies the JWT's signature against the CI platform's published public keys (so it knows the token is genuinely from GitHub, GitLab, or whoever it claims to be from, and hasn't been tampered with), and it evaluates a trust policy — a rule you wrote, in advance, that says which claim values are allowed to receive which role. If the claims match, the broker issues a short-lived cloud credential, typically valid for minutes and scoped to exactly one IAM role. If they don't match — wrong repo, wrong branch, no matching environment — nothing is issued, full stop. No cloud secret was ever pasted into the CI platform for this to work; the CI platform's own signed statement of "this is definitely job X" is the credential's origin.

CI job starts running OIDC issuer signs a JWT naming this exact repo, branch, workflow & environment Cloud STS verifies the signature, then checks the trust policy's claim conditions short-lived credential minutes, one IAM role only requests presents JWT issues, if matched cloud API call then discarded, never stored No step in this chain reads a secret that was sitting in storage before the job started.

Here's the same flow as running Terraform, since a trust policy is infrastructure just like anything else — see IaC security & policy as code for the broader discipline this belongs to:

# One-time setup: register the CI platform as a trusted external identity provider.
# The thumbprint requirement has evolved across AWS's own guidance over time —
# confirm the current value and validation method against AWS's docs before relying on it.
resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea"]
}

# A role that only a job matching these exact claims can ever assume.
resource "aws_iam_role" "deploy_prod" {
  name = "gha-deploy-prod"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
        StringLike   = { "token.actions.githubusercontent.com:sub" = "repo:acme-corp/checkout-service:environment:production" }
      }
    }]
  })
}

And the job side, in a GitHub Actions workflow — note that requesting a token is an explicit permission, not a default:

name: deploy-prod
on:
  push:
    branches: [main]
permissions:
  id-token: write   # without this, the job cannot request an OIDC token at all
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # gated by required reviewers — see the next section
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-prod
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://acme-prod-assets/

This same pattern generalizes past cloud provider credentials. HashiCorp Vault's JWT/OIDC auth method lets a CI job authenticate to Vault the identical way, trading the platform-signed token for a short-lived Vault token instead of a cloud one — useful when a pipeline needs a database password or a third-party API key rather than cloud infrastructure access. The mechanism doesn't change; only what's on the other end of the exchange does. That repetition is the point: once you've built the trust-policy muscle once, it applies everywhere a pipeline currently reaches for a stored secret.

Scoping trust per job, not per pipeline

☺ Like you're 10: A hall pass that only lets you into today's classroom is very different from one that lets you into every classroom, forever.

OIDC federation removes the standing secret, but a trust policy that's too permissive reintroduces the same problem in a new shape. A condition that matches on repo:acme-corp/checkout-service:* — the repository, with no branch, environment, or event-type restriction — grants the exact same access to a job running against a throwaway feature branch as it does to a job deploying from main. Zero trust's "smallest resource, shortest time" tenet has to be applied at the level of the individual job, matched against exactly the claim that job's trigger produces, not at the level of "this pipeline is allowed to touch this cloud account."

JobTriggerClaim the trust policy matchesPermission actually granted
Lint & unit test on a PRpull_request, any branch, possibly a forksub: repo:acme-corp/checkout-service:pull_requestNone to any cloud account — runs entirely against code already checked out, no external credential requested at all.
Terraform plan on a PRpull_request targeting infra filessub: repo:acme-corp/checkout-service:pull_requestRead-only role: can run plan, cannot run apply — the diff is visible to reviewers, nothing changes yet.
Build & push image on mergepush to mainsub: repo:acme-corp/checkout-service:ref:refs/heads/mainWrite access to the staging registry and a staging deploy target only.
Deploy to productionmanual dispatch or tag, gated by a required reviewersub: repo:acme-corp/checkout-service:environment:productionWrite access to the prod deploy role — but the token isn't even requested until a human approves the run.

That last row does double duty. GitHub's environments feature lets you attach required reviewers to a named environment; a job that targets environment: production doesn't run at all — and so never requests a token — until someone approves it. Combine that with a trust policy that only matches the environment:production claim, and you get a genuine two-factor control on the highest-privilege path: a human has to click approve, and the resulting token is only valid for the one role scoped to that exact environment claim. Neither control alone is sufficient — a reviewer gate with a repo-wide trust policy behind it still hands out the same broad credential regardless of who approved what; a tight claim match with no reviewer gate still lets an automated push to main reach production unattended.

◆ Key idea

OIDC federation answers who is this job. A tightly scoped trust policy answers what should this specific job, and only this job, be allowed to do. Federation without careful claim scoping just moves the standing-access problem from a secret store into a trust policy's condition block — it's still one broad grant, just wearing a JWT instead of an access key.

The runner itself is hostile infrastructure now

☺ Like you're 10: Don't hand the delivery truck a key to every warehouse just because it's always been allowed to park in the lot.

Removing the standing credential closes one door. It doesn't make the runner itself trustworthy — the machine executing a build still runs third-party dependencies, third-party base images, and, in most CI platforms, third-party actions that inject arbitrary code directly into the job. The security research firm Cider Security published a widely cited "Top 10 CI/CD Security Risks" framework in 2022 (Cider was acquired by Palo Alto Networks that same year, and the work has since continued as OWASP's Top 10 CI/CD Security Risks project); its most useful contribution to this page's argument is naming Poisoned Pipeline Execution (PPE) as a distinct risk category — a pipeline that runs attacker-influenced code (a malicious dependency, a compromised third-party action, or the contents of an untrusted pull request itself) with the same privilege as the legitimate build. If the runner that executes that code is the same runner holding a federated token, or the same machine that ran a privileged job five minutes earlier, the OIDC work from the previous section buys you very little.

Two concrete practices close that gap, and both follow directly from treating the runner as untrusted rather than as your own trusted machine:

⚠ Watch out — self-hosted runners on a public repository

GitHub's own documentation explicitly warns against attaching a self-hosted runner to a public repository. Anyone can open a pull request against a public repo, and a workflow that runs on a self-hosted runner in response to that PR executes on your infrastructure — with whatever access that runner has, including any long-lived cache or leftover token from a prior job if the runner isn't ephemeral. This is the PPE pattern from above, made concrete: the "attacker-influenced code" is a fork's pull request, and the "privileged execution" is your own hardware. If you must use self-hosted runners for cost or environment reasons, restrict them to private repositories, or gate any workflow that can trigger on them behind required approval for first-time or external contributors.

What a compromised runner is contained by once it's inside a container also matters here — see container runtime security for the same "assume the process is hostile" reasoning applied one layer down, to what a compromised process running inside a build container can actually reach on the underlying host.

Static secret model OIDC federation model runner compromised runner compromised blast radius: everything Every pipeline that ever read this secret. Every branch, every job, every environment it was ever scoped broadly enough to reach. Valid until someone rotates it — commonly months, sometimes years. everything else in this cloud account — never touched this job's role only token's short window, then nothing
🐘 Ellie's drill · 20 min

In a scratch AWS account, register an OIDC identity provider for your CI platform, write an IAM trust policy scoped to one exact branch (using StringLike on the sub claim, as shown above), and confirm a workflow running on that branch can assume the role while a PR from a different branch cannot. Then deliberately loosen the condition to a wildcard covering every branch in the repo, rerun both, and watch the PR workflow gain access it shouldn't have. Tighten it back before you finish. That five-minute mistake, made on purpose in a scratch account, is the entire lesson in this section, felt rather than read.

Continuous verification: policing the pipeline's own definition

☺ Like you're 10: The recipe itself needs a taste-test, not just the cake it eventually bakes.

NIST SP 800-207's "authorization is dynamic and continuously enforced" tenet applies to more than cloud API calls — it applies to the pipeline's own YAML, because that file is code that runs with real privilege, and an attacker who can influence it doesn't need to break OIDC federation at all; they just need the pipeline to do something malicious on their behalf. Two specific practices matter here, and both are checks you run against the workflow definition itself, before it ever executes:

# Run a workflow-definition linter as its own pipeline stage —
# the same "shift left, scan before it runs" logic as SAST, applied to the pipeline's own code.
zizmor .github/workflows/
#   error[unpinned-uses]: action is not pinned to a full length commit SHA
#     --> .github/workflows/deploy-prod.yml:14:9
#   error[template-injection]: code injection via template expansion
#     --> .github/workflows/pr-comment.yml:8:11

This is the same discipline the secure SDLC and secure SDLC gates apply to application code, pointed back at the tool that enforces those gates in the first place. A workflow file is a deploy mechanism with credentials attached; it deserves the same review rigor as anything else that ships to production, and a required, automated check on that file — not a one-time manual review when it was first written — is what makes the review durable instead of something that erodes the first time a deadline is tight.

When the runner isn't trusted, the artifact has to prove itself

☺ Like you're 10: "It came from our build server" stops meaning much once you've spent this whole page explaining why the build server isn't automatically believed.

Here's the thread that ties this whole page together: once you stop trusting the runner by default, "this artifact came off our CI" is no longer a claim you can accept at face value either — it's exactly the claim a compromised runner, like the one in the 3CX incident, would also make. What a verifier needs instead is cryptographic proof of which workflow, with which pinned dependencies, produced this specific artifact — proof that doesn't rely on trusting the runner's own say-so.

The elegant part is that the mechanism is the same one this page already covered. Sigstore and cosign use keyless signing built directly on OIDC federation: a build job's ambient OIDC token — the exact same signed JWT from the earlier sections, just presented to a different verifier — proves the job's identity to Fulcio, Sigstore's certificate authority, which issues a short-lived signing certificate binding that specific workflow identity to a signature over the artifact. The signing event is recorded permanently and publicly in Rekor, Sigstore's transparency log, so the claim "this workflow, this repo, this commit signed this exact artifact at this exact time" is independently checkable by anyone, not asserted by the runner that produced it.

# Keyless signing in CI: no private key ever touches the runner or a secret store.
# cosign picks up the job's ambient OIDC identity automatically when
# `permissions: id-token: write` is set on the workflow, same as the AWS example earlier.
cosign sign --yes ghcr.io/acme-corp/checkout-service@sha256:9f2c1e...

# Anyone can later verify the artifact against the *identity*, not a key file:
cosign verify ghcr.io/acme-corp/checkout-service@sha256:9f2c1e... \
  --certificate-identity "https://github.com/acme-corp/checkout-service/.github/workflows/deploy-prod.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"

The SLSA framework (Supply-chain Levels for Software Artifacts, originally from Google, now an OpenSSF project) formalizes exactly this requirement into graduated build-integrity levels — its higher levels specifically require the build to run on hosted, ephemeral, isolated infrastructure that the build definition itself can't tamper with, which is a direct restatement of this page's "ephemeral runner" section, just written as a supply-chain standard instead of an operational practice. SLSA's exact level structure has been revised more than once since its initial release, so check slsa.dev for the current version rather than treating any specific level number here as fixed. What doesn't change across revisions is the underlying argument: provenance metadata generated by the build itself, unsigned, is just another claim the runner is making about itself. Signed provenance — the artifact plus a Sigstore-backed attestation of exactly what built it — is what actually lets a downstream consumer decide whether to trust it, without having to trust the build server's word alone. See software bills of materials for how that same signed-attestation pattern extends to what's inside the artifact, not just who built it.

🎬 At the Shift-Left Squad
🐘

Ellie the Elephant: I used to carry one static AWS key for this entire pipeline. Every job, every branch, same key, forever — that's not carrying a secret carefully, that's just leaving it out.

🐢

Timmy the Turtle: Which is exactly why I stopped trusting the runner itself. Doesn't matter how clean last week's scan was — every request gets checked, right now, on its own.

🦊

Foxy: Okay, but the runner is inside our own CI account. Why does it need to prove anything to itself?

🦝

Rocky the Raccoon: Because I don't care whose account it's sitting in. I care whether I can get code running on that box. Once I'm on it, "inside the perimeter" means nothing if the perimeter is the only thing vouching for me.

🐘

Ellie the Elephant: So now nothing sits in my drawer at all. The job asks, the cloud checks the claims itself, and hands back a token that's half-expired before anyone could even think about stealing it.

🐦

Pip the Hummingbird: And when the job's done, I still don't just trust that it built the right thing because it came off "our" runner. Same identity, signed straight into the artifact — Fulcio, Rekor, the whole chain. The runner doesn't get the last word either.

🦉

Professor Owl: Say it back in one line: nothing standing, nothing implicit, everything checked on its own merits, every single time. That's the whole page.

✓ Checkpoint

1. State the NIST SP 800-207 tenet this page keeps returning to, and explain why "the runner sits inside our own VPC" is not, by itself, a valid reason to trust a request it makes. 2. Why did a static, long-lived cloud credential in a CI secret store function as an implicit trust root, and what did the CircleCI (January 2023) and 3CX incidents each demonstrate about that model's blast radius? 3. Walk through the OIDC federation flow end to end — what gets signed, what gets checked, and what's actually returned? 4. Give two concrete ways to scope a trust policy per job rather than per pipeline, and explain what problem a too-broad claim match (matching only on repository, with no branch or environment condition) reintroduces. 5. Name two specific practices "treat the runner as untrusted infrastructure" requires, beyond removing standing credentials. 6. Why does artifact signing matter more, not less, once you've stopped trusting the runner by default?

Check your answers
  1. The tenet is that authentication and authorization are evaluated dynamically, per request, and network location is not itself a trust signal. A request from a runner inside your own VPC still has to prove, on its own merits, what it's allowed to do — being on the inside of a network boundary was the old model's trust signal, and zero trust explicitly removes it as a basis for access.
  2. A static credential pasted into a CI secret store grants standing access simply by being possessed — there's no additional check tying it to a specific job, a specific moment, or a specific claim about who's asking. It functions as an implicit trust root because whoever holds it inherits its access, indefinitely, regardless of intent. CircleCI's January 2023 incident showed that a compromise of the CI platform itself (via a stolen, 2FA-bypassing session token) exposed every customer secret CircleCI stored, forcing a rotate-everything response. The 3CX "Smooth Operator" compromise showed the same standing-access problem one layer deeper: an attacker who reached 3CX's build environment through an unrelated earlier compromise didn't need to break the application at all — the build system's own trusted, signed output became the malicious payload.
  3. A CI job requests a token from the CI platform's own OIDC issuer, which signs a JWT whose claims name the exact job — repository, branch/tag, workflow, and environment if applicable. The job presents that JWT to the cloud provider's identity broker (e.g. AWS STS), which verifies the JWT's signature against the issuer's published public keys and then evaluates a pre-configured trust policy's claim conditions. If the claims match, the broker issues a short-lived credential (typically minutes) scoped to exactly one role; nothing is issued if they don't match. No static secret was stored anywhere for this to work.
  4. Two ways: matching the trust policy's claim condition on the specific branch or tag (e.g. ref:refs/heads/main rather than a wildcard), and matching on a named, reviewer-gated environment claim (e.g. environment:production) rather than the repository alone. A too-broad match — repository only, no branch/environment condition — grants the same access to a throwaway feature-branch job as to a production deploy job, which reintroduces the old model's "one broad standing grant" problem inside the new token-based mechanism instead of removing it.
  5. Any two of: running jobs on ephemeral, single-use runners that are destroyed after each job rather than static long-lived ones that can carry residue between jobs; enforcing default-deny network egress from the runner, restricted to an explicit allowlist, so a compromised step can't freely exfiltrate data or reach a C2 endpoint; never attaching a self-hosted runner to a public repository, since any fork's pull request can then trigger code execution on your own infrastructure; and pinning third-party CI actions to a full commit SHA rather than a mutable tag, so a compromised or repointed tag can't silently change what code the runner executes.
  6. Because "this artifact came from our build server" is exactly the claim a compromised runner would also make — trusting it by default is the same implicit-trust mistake this page argues against, just moved from credentials to provenance. Cryptographic signing (Sigstore/cosign, keyless, using the same OIDC identity as the credential-federation flow) and a public transparency log (Rekor) let a downstream verifier independently check which specific workflow produced an artifact, rather than accepting the runner's own unverified word for it.