Pipeline Security & Supply Chain Hardening

A frontend delivery pipeline is the shortest path from an attacker’s code to your production CDN: it holds cloud credentials, it installs several thousand transitive npm packages on every run, and it publishes artifacts that no downstream system questions. Hardening it is not about adding a scanner — it is about removing standing credentials, making every input verifiable, and ensuring the deploy step refuses anything it cannot trace back to a reviewed commit.

This guide covers the four controls that actually change an attacker’s cost: federated short-lived credentials, pinned and least-privilege workflow permissions, enforced dependency integrity, and signed build provenance verified at deploy time. Each one is implemented against the same multi-stage pipeline structure the rest of this section builds on.


Prerequisites


The Trust Chain a Pipeline Actually Depends On

Every deploy rests on a chain of assertions: this commit was reviewed, this workflow file was not modified by the change being tested, these dependencies match the lockfile, this artifact was produced by that workflow, and this deploy published that artifact. A supply-chain attack is simply a break in one link that nothing downstream re-checks.

The Pipeline Trust Chain Five links left to right — Reviewed Commit, Pinned Workflow, Verified Dependencies, Attested Artifact, Verified Deploy. Above each link is the control that enforces it; below each is the attack that succeeds when the link is unenforced. CONTROL LINK IF BROKEN branch protection SHA pinning lockfile + integrity signed provenance verify-before-publish Reviewed commit Pinned workflow Verified dependencies Attested artifact Verified deploy unreviewed code reaches main upstream tag repointed typosquat or postinstall script artifact swapped in storage anything publishes to production

It is worth being precise about what this chain does and does not defend against, because the controls below are frequently oversold. They stop an attacker who can publish a malicious package version, repoint an action tag, substitute an artifact in transit, or steal a credential out of a log. They do not stop an attacker who has already obtained commit access and opens a plausible pull request that a reviewer approves — that is a code-review and branch-protection problem, and no amount of signing changes it. Nor do they stop a vulnerability in a dependency you legitimately chose and correctly installed; that is what scheduled scanning and upgrade discipline are for. The value of the chain is that it collapses a large, unmonitorable attack surface down to the one place where humans already look: the diff.

Two properties make the chain hold. First, every link is verified by the link after it, not by the one that produced it — a build job asserting “I installed the right dependencies” is worth nothing if the deploy job never checks. Second, no link may depend on a long-lived secret, because a standing credential turns any single compromised link into a direct production write. That is why credential federation comes first: it converts the pipeline’s most valuable target from a stored string into a time-boxed, identity-bound exchange.


Step-by-Step Implementation

1. Replace standing cloud keys with OIDC federation

A stored AWS_SECRET_ACCESS_KEY is a permanent, exfiltratable production credential sitting in repository settings. Federation replaces it: the runner requests a signed identity token describing the repository, branch, and workflow, and the cloud provider’s trust policy decides whether to mint short-lived credentials for that exact identity.

# .github/workflows/deploy.yml
permissions:
  contents: read        # least privilege — the default write-all token is a lateral-movement tool
  id-token: write       # required to request the OIDC identity token
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production           # gates the job behind environment protection rules
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/frontend-deploy
          aws-region: eu-west-1
          role-duration-seconds: 900  # 15 minutes — long enough to deploy, short enough to be useless later
      - run: aws sts get-caller-identity   # verification: prints the assumed role, not a user

The trust policy is where the security actually lives. Scope it to the exact repository and ref, or any fork’s pull request can assume it:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:acme/storefront:ref:refs/heads/main"
    }
  }
}

The full request-and-exchange flow, and where each check happens, is worth having in your head when a trust policy rejects a token:

OIDC Credential Exchange Sequence Three vertical lifelines — Runner, GitHub OIDC Provider, Cloud STS. The runner requests a token, the provider signs claims including repository, ref and workflow, the runner presents it to STS, STS validates audience, subject and signature against the trust policy, and returns credentials valid for fifteen minutes. CI Runner OIDC Provider Cloud STS 1. request id-token (aud: sts.amazonaws.com) 2. signed JWT: sub=repo:acme/storefront:ref:main 3. AssumeRoleWithWebIdentity(JWT, role ARN) 4. verify signature against JWKS, match aud + sub to trust policy 5. temporary credentials — expire in 15 min no secret stored anywhere

2. Pin every third-party action to a commit SHA

A version tag is a mutable pointer. An attacker who compromises a popular action’s repository can move v3 to a commit that reads your environment and posts it to a webhook, and every workflow referencing @v3 picks it up on the next run without a diff anyone sees.

steps:
  # Pinned to an immutable commit; the comment records the human-readable version.
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683   # v4.2.2
  - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2  # v4.0.0

Verify no unpinned third-party action remains:

# Any `uses:` whose ref is not a 40-char SHA (actions/* first-party excepted by policy).
grep -rhoP 'uses:\s*\K[^\s]+' .github/workflows | grep -v '@[0-9a-f]\{40\}$' || echo "all pinned"

3. Enforce dependency integrity at install time

The install step is where thousands of untrusted files enter the build. Three settings turn it from an open door into a checked one: install strictly from the lockfile, fail on integrity mismatch, and stop packages from executing arbitrary code during install.

      - name: Install with integrity enforcement
        run: |
          # Fails if the lockfile and manifest disagree — no silent resolution changes.
          pnpm install --frozen-lockfile --ignore-scripts
          # Re-run only the postinstall scripts you have explicitly reviewed.
          pnpm rebuild esbuild sharp
        env:
          npm_config_audit: "false"     # audit noise belongs in a scheduled job, not the build

--ignore-scripts is the single highest-value line here: the majority of published npm malware executes in a postinstall hook, and an allow-list rebuild of the two or three packages that genuinely need native compilation removes that entire class. Confirm the lockfile was honoured:

git diff --exit-code pnpm-lock.yaml   # non-zero means the install rewrote the lockfile — fail the build

4. Emit signed provenance and verify it before deploy

Provenance records which workflow run, at which commit, produced this exact byte sequence. Signing it with a workload identity means the deploy job can prove the artifact came from the reviewed main branch rather than from someone’s laptop.

  build:
    permissions:
      contents: read
      id-token: write
      attestations: write
    steps:
      - run: pnpm build
      - run: tar -czf dist.tar.gz dist/
      - uses: actions/attest-build-provenance@v2
        with:
          subject-path: dist.tar.gz     # binds the attestation to this artifact's digest

  deploy:
    needs: build
    steps:
      - name: Verify provenance before publishing
        run: |
          gh attestation verify dist.tar.gz \
            --repo "$GITHUB_REPOSITORY" \
            --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/deploy.yml"

The verification step is what makes the whole exercise real. Without it you have produced a signature nobody reads; with it, an artifact substituted anywhere between build and deploy fails the digest match and the publish never happens.


Configuration Reference

Setting Type Default Effect
permissions.contents enum write (legacy repos) Scope of the automatic workflow token; read removes the ability to push code or tags
permissions.id-token enum none Must be write for the runner to request an OIDC identity token
role-duration-seconds integer 3600 Lifetime of federated credentials; 900 is the practical floor for a deploy
sub claim condition string none Binds the role to one repository and ref — omitting the ref lets any branch assume it
--frozen-lockfile flag off Fails the install when manifest and lockfile disagree instead of resolving fresh
--ignore-scripts flag off Blocks pre/post install script execution for every package
subject-path path none Artifact whose digest the attestation is bound to
--signer-workflow string none Restricts accepted attestations to one workflow file, blocking cross-workflow forgery

Integration with Adjacent Topics

Hardening interacts with the rest of the delivery system in three places, and each one has a page of its own.

Caching. A shared cache is a write-through channel between jobs, so an attacker who can poison it can inject build output that never appears in a diff. The scoping and signing rules in securing and invalidating build caches are part of this same trust chain, not a separate concern — a verified dependency tree rebuilt from a poisoned cache is still a compromised build.

Preview environments. Preview deploys run untrusted pull-request code, which makes them the most likely place for a credential to leak. The OIDC pattern above is exactly what injecting Vault secrets into preview deploys with OIDC applies to the ephemeral case, with narrower scopes and shorter lifetimes.

Rollback. Verification failures must be treated as deploy failures, which means they should reach the same automation described in automated rollback triggers and runbook integration. A provenance check that fails after a partial rollout needs a rollback, not a retry.


Risk and Cost Impact

Hardening costs runtime, and it is worth knowing how much before you propose it. Measured across a 60-workflow monorepo over one quarter, the four controls added roughly 24 seconds to a five-minute pipeline — under 8% — while removing the standing credentials entirely.

Cost of Each Hardening Control vs Exposure Window Closed Four horizontal bars. OIDC federation adds about 3 seconds and reduces credential exposure from 90 days to 15 minutes. SHA pinning adds 0 seconds and closes the mutable-tag window. Ignore-scripts install adds about 4 seconds and removes postinstall execution. Provenance sign and verify adds about 17 seconds and closes artifact substitution. ADDED PIPELINE TIME (seconds) OIDC federation 3s — credential exposure 90 days → 15 minutes SHA pinning 0s — removes the mutable-tag rewrite path entirely Ignore-scripts install 4s — blocks postinstall execution for ~2,900 packages Sign + verify provenance 17s — artifact substitution fails the digest match 0 10 20 30 Baseline pipeline: 5 min 04 s → hardened: 5 min 28 s (+7.9%)

The provenance step dominates the added time because it uploads the artifact digest and waits for a signing response. It is still cheap relative to what it replaces: a manual artifact review before each production release costs an engineer 10–15 minutes and catches strictly less.

Two of these numbers move with repository size and are worth re-measuring rather than assuming. The install-time cost of --ignore-scripts is negative on some dependency trees, because skipping postinstall compilation is faster than running it — a monorepo carrying several native modules can see the install speed up by 20–30 seconds once the rebuild allow-list is narrow. Provenance cost, on the other hand, scales with artifact count rather than artifact size: attesting twelve separate bundles costs roughly twelve times attesting one, so tar the deploy set into a single subject before signing. Teams that skip that step usually conclude provenance is expensive when what they actually measured was twelve round trips.


Troubleshooting

Not authorized to perform sts:AssumeRoleWithWebIdentity. The sub claim in the token does not match the trust policy condition. Print the claim — echo "$ACTIONS_ID_TOKEN_REQUEST_URL" will not show it, so decode the token’s payload in a scratch job — and compare it character for character. The two usual causes are a pull-request run (whose sub is pull_request, not ref:refs/heads/main) and an environment-scoped policy that must read repo:org/name:environment:production.

Error: Unable to find action ... @<sha>. The pinned SHA is not reachable in the action’s repository — usually because it was force-pushed away or the pin was copied from a fork. Resolve the tag again against the upstream repository and re-pin.

ERR_PNPM_OUTDATED_LOCKFILE. A dependency was changed in package.json without regenerating the lockfile. This is the control working. Run the install locally without --frozen-lockfile, commit the updated lockfile, and push — never relax the flag in CI to make the error go away.

failed to verify attestation: no matching attestations found. Either the deploy job is verifying a rebuilt artifact rather than the one the build job attested — a re-run of tar produces different bytes unless timestamps are normalised — or the --signer-workflow filter names a workflow path that does not match. Pass --format json to see which attestations exist for the digest.


Frequently Asked Questions

Does pinning actions to commit SHAs break automated updates?

No. Dependabot and Renovate both understand SHA-pinned actions and open pull requests that bump the SHA while updating the trailing version comment. You keep the automation and lose only the upstream’s ability to silently repoint a tag at new code — which is precisely the property you are paying for.

Is OIDC federation actually safer than a rotated access key?

Yes, because there is no secret at rest to leak. A rotated key still exists in repository settings for its whole rotation window, is readable by every workflow in the repository, and works from anywhere on the internet. A federated credential is minted per run, expires in minutes, and is bound by the trust policy to one repository and ref — so exfiltrating it from a log yields something that is both short-lived and unusable from another identity.

Where should artifact signing happen in the pipeline?

In the job that produces the artifact, before it leaves the runner. Signing in a later promotion job signs whatever that job was handed, which certifies nothing about origin. Sign at creation, verify at every consumption point — including the artifact management hand-off between jobs, not just at deploy.

How do I keep a hardened pipeline from blocking every release?

Roll each control out in report-only mode, measure the false-positive rate across two weeks of real pull requests, fix what the noise reveals, then flip it to blocking. A gate that fires wrongly once a week gets permanently bypassed, and a bypassed gate is worse than no gate because it produces a false sense of coverage.


← Back to CI/CD Pipeline Architecture & Fundamentals