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.
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.
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):
- Every resource requires authentication and authorization, evaluated per request — not once, at the start of a session, and then implicitly trusted for everything that follows. A pipeline job calling a cloud API for the fifth time in ten minutes gets checked the fifth time exactly as rigorously as the first.
- Network location is not a trust signal. A request from inside your VPC, your CI account, or your own GitHub organization is not, by virtue of that location, more trustworthy than a request from anywhere else. The old model — "it's on our network, so it's us" — is precisely the assumption zero trust removes.
- Access is scoped to the smallest resource that satisfies the request, for the shortest time that does the job — a session-scoped grant, not a standing one. This is the tenet that turns directly into "short-lived tokens" later in this page.
- The enterprise assumes some part of its own infrastructure is already compromised, and designs controls that still hold up under that assumption, rather than controls that only work if nothing inside the perimeter has gone wrong yet.
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:
- CircleCI, January 2023 — CircleCI disclosed that malware on an engineer's laptop stole a session token that bypassed two-factor authentication, giving an attacker access to a subset of CircleCI's production systems. Because CircleCI's platform holds customer secrets in a form its own running application can decrypt, the practical advice CircleCI gave every customer was blunt: rotate every secret ever stored in CircleCI — API tokens, cloud credentials, everything. The incident is a clean illustration of the core problem this page is about: a long-lived credential sitting in a third-party platform's store has a blast radius equal to that platform's own security posture, not yours, and neither party finds out how large that radius is until after the fact. (CircleCI's own public incident report has the full technical detail — treat this as a summary, not the source of record.)
- The 3CX "Smooth Operator" compromise, March 2023 — publicly documented by Mandiant and CrowdStrike as a double supply-chain attack: an employee's personal computer, also used for work, had been infected months earlier by a trojanized build of unrelated financial-trading software, and that foothold was used to reach into 3CX's own build environment and trojanize the legitimate 3CX desktop application that then shipped to real customers. The build server producing a trusted, signed artifact was itself the compromised link — precisely the scenario this page's later sections on runner trust and artifact provenance are built to address. (Again, verify specifics against Mandiant's and CrowdStrike's public writeups; incident details of this depth are easy to oversimplify in a summary.)
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.
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.
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."
| Job | Trigger | Claim the trust policy matches | Permission actually granted |
|---|---|---|---|
| Lint & unit test on a PR | pull_request, any branch, possibly a fork | sub: repo:acme-corp/checkout-service:pull_request | None to any cloud account — runs entirely against code already checked out, no external credential requested at all. |
| Terraform plan on a PR | pull_request targeting infra files | sub: repo:acme-corp/checkout-service:pull_request | Read-only role: can run plan, cannot run apply — the diff is visible to reviewers, nothing changes yet. |
| Build & push image on merge | push to main | sub: repo:acme-corp/checkout-service:ref:refs/heads/main | Write access to the staging registry and a staging deploy target only. |
| Deploy to production | manual dispatch or tag, gated by a required reviewer | sub: repo:acme-corp/checkout-service:environment:production | Write 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.
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:
- Ephemeral, single-use runners. A runner that's provisioned fresh for one job and destroyed the moment that job ends can't carry residue — cached files, leftover environment variables, a background process — from one job into the next. A static, long-lived self-hosted runner that serves job after job is a shared machine in every sense that matters: whatever a compromised job A leaves behind, job B inherits, whether or not job B's own code did anything wrong.
- Default-deny egress. A compromised build step's most dangerous asset usually isn't the code it can run locally — it's the network connection it can make outward, to exfiltrate the (admittedly short-lived) token it does have, pull a second-stage payload, or reach a command-and-control endpoint, exactly the pattern in both the CircleCI and 3CX incidents cited earlier. Restricting a runner's outbound traffic to an explicit allowlist — the package registries and APIs a build genuinely needs, nothing else — turns "the runner can reach anywhere on the internet" into "the runner can reach four specific hostnames," which is a much smaller attack surface even if a step is compromised. StepSecurity's Harden-Runner is a widely used open-source implementation of exactly this for GitHub Actions specifically — it installs an egress-monitoring agent on the runner and can enforce a network allowlist directly in the job.
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.
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:
- Pin third-party actions to a commit SHA, never a mutable tag.
uses: some-org/some-action@v3trusts whatever commitv3happens to point to at the moment your job runs — and a tag is a pointer the action's maintainer (or anyone who compromises their account) can move. Pinning to the full commit SHA, as in the earlier workflow example, means the code you audited is the code that runs, permanently, regardless of what the tag is later repointed to. - Lint the workflow file for injection and privilege issues, not just the application code. zizmor, an open-source static analyzer built specifically for GitHub Actions workflows, checks for exactly this class of problem — an unpinned action, a step that interpolates untrusted input (like a pull request's title or body) directly into a shell command via
${{ }}template expansion, which is a textbook script-injection vector, or a job requesting broaderpermissionsthan it uses.
# 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.
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.
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
- 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.
- 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.
- 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.
- Two ways: matching the trust policy's claim condition on the specific branch or tag (e.g.
ref:refs/heads/mainrather than a wildcard), and matching on a named, reviewer-gatedenvironmentclaim (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. - 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.
- 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.