Writing
Hardening the deployment pipeline: supply chain, secrets, containers and the edge
Where a pipeline is actually attacked, in order of likelihood — and the specific GitHub Actions and AWS controls that close each one, including the permission default almost every repository gets wrong.
6 min read
Most "secure your pipeline" advice is a list of scanners. Scanners are useful and they are not where pipelines get compromised. Pipelines get compromised through credentials with too much scope and dependencies nobody chose, and both have specific, unglamorous fixes.
This is ordered by how often each thing is the actual entry point.
The default that is wrong in almost every repository
By default, GITHUB_TOKEN in a workflow has broad write permissions. Any step in that
workflow — including one inside a third-party action you did not write — can use it.
# At the top of every workflow. Nothing else, unless a job needs it.
permissions:
contents: read
jobs:
release:
permissions:
contents: write # granted where it is needed, and nowhere else
id-token: write # for OIDCPin your actions to a commit, not a tag
uses: some/action@v3 resolves a tag, and a tag is mutable. Whoever controls that repository
can move it, and your pipeline runs the new code on the next build with no diff anywhere.
# ✗ mutable
- uses: actions/checkout@v4
# ✓ immutable, with the version in a comment so a bot can update it
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1Let Dependabot propose the bumps. The point is not to never update — it is that an update arrives as a reviewable change rather than silently.
Stop storing long-lived cloud credentials
An AWS_SECRET_ACCESS_KEY in repository secrets is a permanent credential sitting in a system
that runs arbitrary code from pull requests. OIDC removes it entirely: GitHub mints a
short-lived token, AWS trusts the issuer, and nothing long-lived exists to leak.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: eu-west-1The trust policy is where the security actually lives, and the condition below is the one people get wrong:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:teispace/app:ref:refs/heads/main"
}
}
}pull_request_target is the footgun
pull_request workflows run without secrets, which is correct: the code is untrusted.
pull_request_target runs with secrets, in the context of the base repository — and if it
also checks out the pull request's code, you have executed a stranger's code with your
credentials.
Prefer pull_request, and accept that it has no secrets
That constraint is the security property, not an inconvenience to work around.
If you must use pull_request_target, do not check out the PR ref
Use it only for things that need no untrusted code — labelling, commenting.
Require approval for first-time contributors
A repository setting, and it costs nothing.
Dependencies: the fixes that matter more than the scanner
Commit the lockfile and install with a frozen resolution
yarn install --immutableornpm ci. An install that is allowed to resolve differently in CI than locally means CI tested something else.Disable lifecycle scripts where you can
postinstallis arbitrary code execution at install time, and it is the most direct supply chain path there is.Keep the runtime surface small, deliberately
This site ships nine runtime dependencies. Everything else — bundler plugins, icon generation, syntax highlighting, the MDX toolchain — is a devDependency, so it runs at build and reaches no user. That is a security property as much as a performance one.
Then add the scanners
Dependency review on pull requests, secret scanning with push protection, CodeQL on a schedule. They are worth having, and they catch less than the four items above.
Containers: the base image is most of your CVE count
Use a distroless or minimal runtime image
Most vulnerabilities in a scan are in packages your application never calls — a shell, a package manager, coreutils. Removing them removes the findings and the exploitation path.
Multi-stage, so build tooling never ships
Compilers and dev dependencies belong in a stage that is discarded.
Run as a non-root user, with a read-only root filesystem
Both are one line and both meaningfully limit what a compromise can do.
Scan the built image, and fail on high severity in what you actually run
Scanning the base image alone misses what your own layers added.
FROM node:22-slim AS build
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --immutable
COPY . .
RUN yarn build
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/.next/standalone ./
USER nonroot
CMD ["server.js"]The edge: WAF as a rate limiter first
A managed WAF rule set is worth enabling and is not the main value. The main value is that the edge is the cheapest place to drop traffic — before it costs you a container, a database connection or a third-party API call.
| Control | Stops |
|---|---|
| Rate limit per IP per path | Credential stuffing, scraping, accidental self-DoS |
| Tighter limit on auth endpoints | Brute force — a much lower limit than the rest of the site |
| Request size cap | Memory exhaustion via large bodies |
| Bot control on expensive paths | Search and report endpoints being crawled |
| Managed rule sets | Generic injection and traversal attempts |
Response headers are the cheapest control on this list
Set once, in one place, and they close whole categories:
{
'Content-Security-Policy': "default-src 'self'; object-src 'none'; base-uri 'self'",
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
}CSP is the one that takes real work, because it requires knowing what your page actually loads — which is itself a useful audit. Run it in report-only mode until the reports are quiet.
The order to do this in
Set explicit permissions on every workflow
Five lines, and it is the single highest-value change.
Replace static cloud keys with OIDC, and constrain the sub claim to a branch
A wildcard sub is the same as no protection.
Pin actions to commit SHAs and let a bot propose updates
Updates should arrive as diffs.
Freeze installs, disable lifecycle scripts, keep the runtime surface small
Then add scanners.
Distroless runtime, non-root, read-only, scan the built image
Most CVEs are in the base you did not need.
Rate limit at the edge; deploy every rule in count mode first
And set the response headers, which cost nothing.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.