Securing CI/CD Pipelines: Controlling Who Triggers What
The Problem No One Fixes Until It’s Too Late
Most teams treat their CI/CD pipeline as a build tool. Attackers treat it as a privileged execution environment — one that already has your cloud credentials, your registries, and your deployment keys baked in.
Three attack surfaces keep showing up in post-mortems for public and open-source repositories:
- Fork pull requests that inherit trusted pipeline permissions
- Overly broad
GITHUB_TOKENor job-level permission scopes - OIDC role assumptions with claims too wide to be meaningful
None of these require a zero-day. They just require a malicious PR — or even an honest contributor who makes a mistake.
Fork PRs: Untrusted Code in a Trusted Context
When a contributor forks your repo and opens a PR, GitHub Actions has two event types available: pull_request and pull_request_target.
The difference is critical.
pull_requestruns the workflow from the fork’s branch, in a restricted context with no access to secrets. Safe for most CI tasks.pull_request_targetruns the workflow from the base branch, with full access to secrets and environment variables — even when triggered by a fork.
pull_request_target exists for legitimate reasons: it lets you post comments back to a PR, or run deployment previews that need deploy keys. But if you check out the PR’s code inside a pull_request_target workflow without thinking, you’ve handed an arbitrary contributor root-equivalent access to your pipeline.
The Dangerous Pattern
on:
pull_request_target:
types: [opened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ← checks out fork code
- run: ./scripts/build.sh # ← runs untrusted code with full secret access
This is the classic pull_request_target footgun. The solution is to separate concerns: use pull_request for jobs that need to run untrusted code, and use pull_request_target only for jobs that post results back — never mixing secret access with fork code execution.
The Safe Split Pattern
Run the potentially dangerous work in an isolated job triggered by pull_request (no secrets), then use a second workflow triggered by workflow_run to do the privileged posting or deployment — only after the first workflow passes.
# workflow 1: runs on fork code, no secrets
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make test
# workflow 2: triggered after workflow 1, runs on base branch with secrets
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
report:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Post coverage comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// post comment using artifact from prior run
GITHUB_TOKEN Permissions: Least Privilege at the Workflow Level
By default, GITHUB_TOKEN is issued with write permissions across most GitHub resource types. That’s a wide blast radius for what should be a scoped build token.
Set a restrictive default at the top of every workflow, then grant only what each specific job needs:
permissions: read-all # deny everything by default
jobs:
release:
permissions:
contents: write # only this job can publish releases
id-token: write # only this job fetches OIDC tokens
packages: write # only this job pushes to GHCR
steps:
- ...
The permissions: read-all top-level block is your global default. Individual jobs can escalate from there — but no job gets write access it didn’t ask for.
For repos where you never need write access from CI (read-only audit pipelines, security scans, docs previews), go further:
permissions: {}
This disables the token entirely, forcing any downstream steps to use explicit credentials.
OIDC Token Scoping: Don’t Trust the Repo Name Alone
OIDC federation is the right way to authenticate CI to AWS, GCP, or Azure — no long-lived secrets to rotate or leak. But the security guarantee is only as tight as the trust policy on the receiving side.
A common misconfiguration: the IAM role’s trust policy only validates the repository name, not the branch or workflow context.
Too Broad (AWS Example)
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
}
}
}
This allows any workflow in myorg/myrepo — including one triggered by a fork PR — to assume the role.
Tighter: Lock to Specific Refs and Workflows
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main",
"token.actions.githubusercontent.com:workflow": "Deploy to Production"
}
}
}
By adding the workflow claim, you ensure only a specific named workflow — not an attacker-supplied one — can assume the role. Combine this with the environment claim when using GitHub Environments:
"token.actions.githubusercontent.com:environment": "production"
This means even a compromised workflow on main that isn’t inside the production environment block can’t get the production role.
Environment Protection Rules: Your Last Gate Before Deployment
GitHub Environments let you require approvals before any job targeting that environment runs. This is your backstop for production deployments.
Key settings to configure:
- Required reviewers — at least one human must approve before secrets are exposed
- Deployment branches — restrict to
mainor protected branches only - Wait timer — add a mandatory delay window for emergency cancellation
jobs:
deploy:
environment: production # triggers approval gate + scoped secrets
runs-on: ubuntu-latest
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }} # only available after approval
run: ./deploy.sh
Environment-scoped secrets are only injected after a reviewer approves — they never appear in PR preview runs or fork-triggered workflows.
GitLab CI: Protected Branches and Variable Scoping
GitLab’s equivalent controls work at the variable and runner level.
- Protected variables — only exposed on protected branches and tags. Fork MR pipelines don’t qualify.
- Protected runners — high-privilege runners that access production credentials are tagged and locked to protected refs only.
rules:withif: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH— scope deployment jobs explicitly, preventing any feature branch or MR from triggering a production job.
deploy-prod:
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
environment:
name: production
script:
- ./deploy.sh
The $CI_PIPELINE_SOURCE == "push" check excludes merge request pipelines from triggering this job, even if someone targets main.
A Quick Access-Control Checklist
- [ ] All workflows have
permissions: read-allorpermissions: {}at the top level - [ ] No workflow uses
pull_request_targetand checks out fork code in the same job - [ ] OIDC trust policies validate
workflow,ref, andenvironmentclaims — not justrepo - [ ] Production secrets live in environment-scoped stores, behind required reviewers
- [ ] Protected runners (GitLab) or self-hosted runner groups (GitHub) restrict which refs can use privileged hardware
- [ ]
CODEOWNERScovers.github/workflows/so workflow changes require senior review
Adding CODEOWNERS protection to the workflow directory is often overlooked — it ensures that any change to the pipeline itself requires approval from a specific team, not just any contributor with merge rights.
The Underlying Principle
Every CI/CD system is a privilege-escalation machine by design: it takes code and turns it into running infrastructure. The controls above aren’t optional hardening — they’re the boundary between “code review” and “production access.”
Start with the permissions block and the fork PR split. Those two changes close the most common attack paths with no external tooling required. Then layer in OIDC claim hardening and environment gates as your deployment surface grows.