Planning a Secure CI/CD Pipeline
Before you write a single workflow step, a production-grade pipeline needs a plan. That plan is not about which testing framework to run first — it is about who and what can touch your code, your secrets, and your deployment targets at each stage. A pipeline that builds and deploys correctly but hands a fork contributor’s pull request access to your production credentials is not a working pipeline; it is an incident waiting for a trigger. This lesson gives you a repeatable way to think through trust boundaries, permissions, and approval gates before you write the YAML.
Overview / How it works
A CI/CD pipeline is really three related but distinct practices, and mixing them up leads to unsafe defaults:
- Continuous integration (CI): every change is automatically built and tested as soon as it is pushed or opened as a pull request, so integration problems surface within minutes instead of at release time.
- Continuous delivery: every change that passes CI is automatically packaged into a release-ready artifact, but a human deliberately approves the actual release to production.
- Continuous deployment: every change that passes CI and any required checks is deployed to production automatically, with no manual approval step.
Security planning changes depending on where your pipeline sits on that spectrum. Continuous deployment removes the human approval gate, so the automated checks and permission boundaries have to do all the work that a human reviewer would otherwise do. That is why planning comes before implementation: you decide which stages need a human, which need an automated gate, and which secrets each job is allowed to see, before you decide which actions or scripts to run.
The core planning exercise is mapping trust boundaries: for every job in the pipeline, ask “what code is running here, who supplied it, and what can it reach if it is malicious?” A workflow triggered by a push from a maintainer with write access is a different trust boundary than a workflow triggered by a pull request from an anonymous fork. Treat them differently by default.
Syntax or workflow structure
Three structural elements do most of the security work in a GitHub Actions pipeline, and you should decide on values for all three during planning, not while debugging a failed run:
permissions:scopes what the automatically generatedGITHUB_TOKENcan do against the repository and related APIs. Set it at the workflow level as a safe default, then narrow further per job.on:triggers: decide whether a workflow reacts topush,pull_request, or the far riskierpull_request_target, and whether it runs on GitHub-hosted or self-hosted runners.environment:attaches a job to a named, protectable target (such asstagingorproduction) that can require reviewer approval and expose environment-scoped secrets that other jobs cannot see.
A minimal, security-conscious skeleton looks like this before any build or test logic is added:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: echo "Build and test steps go here"
Notice two choices already baked in: permissions: contents: read is set at the workflow level, and again at the job level, so the token cannot write to the repository, open issues, or publish packages unless a later job explicitly asks for that scope. The concurrency block cancels superseded runs on the same branch, which reduces the number of stale, possibly conflicting deployments in flight.
Examples
Example 1: locking down the default token. Start every new workflow file by declaring the narrowest permissions it needs. A workflow that only builds and runs tests needs nothing more than read access to the repository contents.
permissions:
contents: read
Expected behavior: the GITHUB_TOKEN generated for this workflow run can check out code but cannot push commits, create releases, or write packages. If a step later in the workflow tries to use the token to push a tag, it fails with a permissions error — which is the point. You add scope back explicitly, per job, when a job genuinely needs it.
Example 2: scoping a job that needs to publish. A release job that publishes a container image needs write access to packages, but only that job — not the whole workflow.
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: echo "run test suite"
publish:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- run: echo "build and push image"
Expected behavior: the test job runs with read-only access and cannot publish anything, even if its steps were compromised by a malicious dependency. Only publish, which runs after test succeeds, carries the extra packages: write scope, and only for the duration of that job.
Example 3: gating a deployment behind an approved environment. Deployment jobs should target a named environment with required reviewers, so a human signs off before production credentials are used, even in an otherwise automated pipeline.
jobs:
deploy:
needs: [test, publish]
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: echo "deploy using OIDC-issued credentials, no static secret stored"
Expected behavior: because the job references the production environment, GitHub pauses the job until a configured reviewer approves it, and only then injects that environment’s secrets. The id-token: write permission lets the job request a short-lived OpenID Connect token to authenticate to a cloud provider, instead of relying on a long-lived static credential stored as a secret.
Step by step
- Inventory what the pipeline touches. List every system it reads from or writes to: the repository, package registries, container registries, cloud infrastructure, notification channels.
- Draw trust boundaries around triggers. Separate “runs on trusted, reviewed code” (pushes to protected branches, pull requests from collaborators) from “runs on arbitrary external input” (pull requests from forks, issue comments, webhook payloads).
- Assign minimal permissions per job. Default the workflow to
contents: read, then add exactly the scopes each job needs, on that job only. - Decide which stages need human approval. Map this to whether you are practicing continuous delivery (approval before release) or continuous deployment (no approval, so automated checks must be strict).
- Protect deployment targets with environments. Attach required reviewers, wait timers, and environment-scoped secrets to any job that touches staging or production.
- Plan credential issuance. Prefer short-lived, OIDC-issued cloud credentials over long-lived secrets stored in repository or organization settings.
- Plan for rollback. Decide in advance how a bad deployment gets reverted — redeploying a previous image digest, running a rollback script, or triggering a separate rollback workflow — before you need it under pressure.
Common Mistakes
Mistake 1: using pull_request_target to run untrusted code with privileged access. pull_request_target runs in the context of the base repository, which means it has access to repository secrets even when triggered by a fork’s pull request. A workflow that checks out the fork’s head commit and then runs its code (tests, build scripts, linters) under that trigger hands an anonymous contributor a path to your secrets.
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Why this is dangerous: the fork’s code, including its package.json install scripts, runs with DEPLOY_TOKEN in the environment. A malicious pull request can exfiltrate the secret before a maintainer ever reviews the diff.
Correction: run untrusted fork code under the ordinary pull_request trigger, which does not expose repository secrets and runs with a read-only token by default. If a privileged step is genuinely required after review (for example, posting a comment with test results), split it into a second workflow triggered by workflow_run that only reads artifacts — never re-executes fork code.
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
Mistake 2: relying on default token permissions instead of declaring them. Omitting the permissions: key entirely leaves the GITHUB_TOKEN at whatever the repository or organization default is, which on many repositories still grants broad read/write access. A workflow written this way often works fine in testing, then becomes a liability the day a dependency is compromised, because every job silently has write access it never needed.
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
Correction: always declare permissions explicitly, at minimum contents: read at the workflow level, so the safe default holds regardless of repository or organization settings, and add scopes back only on the specific jobs that need them.
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
Best Practices
- Default to
contents: read, escalate per job. Never leavepermissionsunset and rely on org defaults. - Treat fork pull requests as untrusted input. Use
pull_request, notpull_request_target, unless you have a specific, carefully isolated reason, and never run fork-supplied code in a job that also has secrets. - Pin third-party actions. A tag like
@v4can be a moving target if the publisher force-pushes it; pinning to a full commit SHA guarantees the exact code that runs, at the cost of needing to update the SHA manually when you want a new version. Many teams pin only third-party, less-audited actions and use version tags for actions published by GitHub itself. - Use environment protection rules for anything that deploys. Required reviewers, wait timers, and branch restrictions on an
environmentturn continuous deployment back into a checkpointed process without slowing down every ordinary CI run. - Prefer OIDC federation over static cloud credentials. A workflow that requests a short-lived token via
id-token: writehas nothing long-lived to leak; a static secret sitting in repository settings does. - Deploy by digest, not by mutable tag. A tag such as an app’s
latesttag can point to different content over time; an image digest always refers to exactly one immutable set of bytes, which makes rollbacks and audits reliable. - Never let a workflow print a secret. Do not echo, log, or interpolate secret values into shell commands; GitHub masks known secret values in logs, but that masking is not a substitute for keeping secrets out of commands in the first place.
- Plan the rollback path before you need it. Know in advance which previous image digest or release you redeploy, and keep that path exercised, not just documented.
Practice Exercises
- For a repository you maintain, list every job a CI/CD pipeline would need (test, lint, build, publish, deploy to staging, deploy to production) and write down, for each one, the minimum
permissionsscopes it actually needs. - Take a workflow that currently uses
pull_request_targetwith a fork checkout, and rewrite its trigger and permissions so that untrusted fork code never runs with access to secrets. Note where a second, secret-free workflow would be needed for any post-review automation. - Draft an
environmentconfiguration for a production deployment job: name the environment, decide whether it needs required reviewers, and list which secrets should be scoped to that environment rather than to the whole repository.
Summary
Planning a secure pipeline means deciding, before you write workflow YAML, which triggers carry untrusted input, which jobs need which token scopes, and which deployments need a human or an automated gate to approve them. Default every workflow to contents: read, treat pull_request_target and self-hosted runners as high-risk unless proven otherwise, protect deployment targets with named environments, and prefer short-lived OIDC credentials and immutable image digests over static secrets and mutable tags. Every example in this lesson is a template — adapt branch names, environment names, registries, and credential mechanisms to your own infrastructure, and never copy a real host, token, or key into a workflow file.
