skip to content

How would you keep production credentials unreachable from GitHub Actions runs on fork pull requests?

level: principalimportance: should knowfreq 40%

answer

  1. the safe default is already the platform default
  2. one trigger deliberately reverses that default
  3. checking out the head is what makes it dangerous
  4. separate the untrusted half from the privileged half
  5. put the credential behind an approval, not a condition

basics

~20 s

Rely on the default that fork pull requests get no secrets and a read-only token, keep privileged work out of pull-request-triggered workflows entirely, and put real credentials behind environments with reviewers so any privileged job must be approved before it starts.

solid answer

~50 s

Start from the platform default: a `pull_request` run from a fork receives no secrets and a read-only `GITHUB_TOKEN`, so an untrusted contributor's code cannot reach anything. The danger is what teams add to work around that. `pull_request_target` runs with the base repository's secrets and a writable token in the context of the base branch; combined with a checkout of the pull-request head it executes untrusted code with full credentials, which is the classic compromise. My design is to split the pipeline: the untrusted half builds and tests with no credentials, and any privileged follow-up runs in a separate workflow triggered by `workflow_run` or by merge, reading artifacts rather than re-running contributor code. Credentials live on environments with required reviewers and branch policies, third-party actions are pinned to SHAs, and OIDC roles pin the `sub` claim to a branch or environment so a pull-request context cannot assume them.

code

yaml · 39 lines
yaml
# .github/workflows/pr-build.yml  (runs untrusted code, holds nothing)
name: pr-build
on: pull_request
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: site
          path: dist

---
# .github/workflows/pr-publish.yml  (privileged, never runs PR code)
name: pr-publish
on:
  workflow_run:
    workflows: [pr-build]
    types: [completed]
permissions:
  contents: read
jobs:
  publish:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    environment: preview        # credential gated here
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: site
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
      - run: ./publish-preview.sh
        env:
          PREVIEW_TOKEN: ${{ secrets.PREVIEW_TOKEN }}

go deeper

for a junior

Know that fork pull-request runs get no secrets and a read-only token by default, and that this is deliberate rather than a bug to work around.

for a middle

Explain why pull_request_target reverses that default and what makes it unsafe: the base workflow runs with secrets, and checking out the head executes untrusted code inside that context.

for a senior

Design the split pipeline — untrusted build with no credentials, privileged follow-up via workflow_run or post-merge consuming artifacts — and pin actions to SHAs in any workflow that holds secrets.

for a principal

Own the boundary as policy: credentials behind environment gates, OIDC subjects pinned so a pull-request context cannot assume a role, hosted runners for public repositories, action allowlists, approval for outside contributors, and a rehearsed rotation path.

## The threat, stated precisely A fork pull request is arbitrary code from someone with no access to your repository, proposed for execution on your CI. If that execution has credentials in its environment, the contributor has those credentials — a build script, a test, a dependency's install hook, or a modified workflow step can print, upload, or use them. Masking does not help, because the attacker controls the encoding. ## Layer 1: keep the default GitHub already makes fork pull-request runs safe by default: no secrets, a read-only `GITHUB_TOKEN`, and no access to environment-scoped values. The right posture is to leave that intact and design pipelines that do not need to break it. Most CI genuinely does not: compiling, linting, and unit tests need no credential. If a test needs a service, prefer an ephemeral container in the job over a shared account. ## Layer 2: understand what breaks it Three workarounds account for nearly all real incidents. **`pull_request_target`.** This trigger exists so a workflow can label or comment on a fork pull request; it runs the workflow file from the **base** branch, in the base repository's context, with secrets and a writable token. That is safe only while the job does not execute head code. Adding `actions/checkout` with `ref: ${{ github.event.pull_request.head.sha }}` and then running a build hands full credentials to the contributor. If you use this trigger, keep the job to metadata operations, never check out the head, and never run a script or install dependencies from it. **Self-hosted runners on public repositories.** A fork pull request that runs on your own hardware executes untrusted code inside your network, with whatever the machine can reach and whatever previous jobs left on disk. Public repositories should use hosted runners; if self-hosted is unavoidable, make them ephemeral and network-isolated. **Unpinned third-party actions.** A mutable tag can be repointed at new code, and every action in the job can read everything the job holds. Pin to full commit SHAs, especially in any workflow that does hold credentials. ## Layer 3: split the pipeline The durable design separates untrusted execution from privileged execution: 1. A `pull_request`-triggered workflow builds and tests with no secrets, uploading whatever artifacts are needed. 2. A second workflow, triggered by `workflow_run` on the first (or simply running after merge), performs privileged work — publishing, commenting with rich results, deploying a preview. It runs from the base branch's workflow file and consumes the artifact rather than re-executing contributor code. The rule is that the privileged workflow never runs contributor-authored logic. Treat everything crossing the boundary — artifact contents, branch names, pull-request titles — as untrusted data, never as code and never spliced into a shell command via `${{ }}`. ## Layer 4: make privileged jobs approvable Put real credentials on environments. A job that needs the production credential must declare `environment: production`, which means it waits for required reviewers and satisfies the deployment branch policy before it is dispatched — and no secret reaches a runner in the meantime. For cloud access, use OIDC and pin the trust policy's `sub` claim to a ref or environment so that a pull-request context, whose subject takes a different form, cannot assume the role at all. That is a stronger control than any workflow condition, because it is enforced outside the repository. ## Layer 5: verify and constrain - Set restrictive `permissions:` defaults, ideally `contents: read`, so even a workflow without secrets holds a near-powerless token. - Require approval for workflow runs from first-time or outside contributors, so no fork pull-request run starts unwatched. - Restrict which actions may be used at organization level, and review any change to a workflow file in a pull request with the same care as production code. - Rehearse rotation: assume something will leak eventually, and know how long it takes to revoke each credential class. OIDC roles need a policy edit; stored keys need a rotation across every consumer. ## The judgment being tested The interviewer is checking whether you reach for a policy control instead of a clever condition. Answers that hinge on `if: github.event.pull_request.head.repo.full_name == github.repository` are weak: the check is in a file a pull request can propose changing, and it protects one job rather than the credential. Answers that move the credential behind an environment gate or a federated role condition hold regardless of what the workflow file says.

  • Why is pull_request_target considered dangerous when combined with checking out the head commit?
    The trigger runs the base branch's workflow with the base repository's secrets and a writable token — that is its purpose, so it can label or comment on fork pull requests. Checking out the head SHA and then building or testing executes the contributor's code inside that privileged context, handing them the credentials. Keep such jobs to metadata only.
  • How do you let a fork pull request produce a preview deployment without exposing the deploy credential?
    Split it. The `pull_request` workflow builds the site with no secrets and uploads an artifact. A separate workflow triggered by `workflow_run` downloads that artifact and deploys it, running from the base branch with credentials on an environment. The privileged half never executes contributor code; it only publishes bytes the untrusted half produced.
  • Why is an if: condition comparing the head repository to the base repository a weak control?
    It lives in a file that a pull request can propose changing, it protects only the job it is written on, and it fails open if someone copies the job without it. Controls that live outside the workflow — environment protection rules, OIDC trust policies pinned by `sub`, organization action restrictions — hold regardless of what any workflow file says.
  • What is the risk of running fork pull requests on self-hosted runners for a public repository?
    Untrusted code executes on your hardware, inside your network, and may see leftovers from previous jobs on the same machine. The mitigations are to use hosted runners for public repositories, or to make self-hosted runners ephemeral and network-isolated, plus requiring approval before an outside contributor's run starts.

saying these in an interview costs you the question

  • Relies on an if: check inside the pull-request workflow
  • Uses pull_request_target and checks out the head
  • Runs fork pull requests on self-hosted runners
  • Assumes log masking prevents secret exfiltration
  • Grants secrets to fork PRs to make tests pass

context