Securing External PRs in GitHub Actions: pull_request, Permissions, and Workflow Isolation

Understand permission boundaries for external pull requests in GitHub Actions, with safe patterns for pull_request_target, GITHUB_TOKEN, and workflow isolation.

When adding automatic labels, welcome comments, or review bots to GitHub Actions, you may hit a permissions problem: a normal pull_request workflow cannot access certain repository secrets or write permissions. Some workflows work around this by switching to pull_request_target. That runs in the target repository’s privileged context. If the workflow then checks out and executes a contributor’s code, a malicious change could read secrets, modify the repository, or poison a cache.

The key is not to memorize one trigger as “safe” and another as “unsafe.” Separate untrusted code execution from privileged operations. This guide explains the event differences, least privilege, dangerous combinations, usable patterns, and validation steps.

Separate the event context from the code being run

A GitHub Actions trigger determines the event context, token permissions, and secrets visible to a workflow. For pull requests from outside contributors, the common choices are pull_request and pull_request_target. Choose between them based on the work being performed, not simply on whether the workflow needs to comment on a PR.

Trigger Code checked out by default Typical permissions for fork PRs Suitable work
pull_request A test merge commit Usually restricted; repository secrets are not available for fork PRs Build, test, static analysis
pull_request_target The base branch context Target repository permissions; may include write access and secrets Read PR metadata, add labels, post comments
workflow_run A follow-up workflow runs when another workflow completes The follow-up may have higher permissions Controlled post-processing

Code run by pull_request can include contributor-controlled tests, build scripts, and dependency configuration. Treat its runner as processing untrusted input. Its restricted permissions are part of the security design; do not add long-lived secrets just to make tests convenient.

The workflow definition for pull_request_target comes from the target repository’s default branch and runs in the target repository context. It can read PR metadata, which makes it useful for labels and comments. Because it may have a write token and access to repository secrets, never check out a fork’s branch and execute its code in that workflow.

workflow_run is not automatically a safe “second stage.” It can run with elevated permissions, while artifacts, caches, and files created by the earlier workflow may have been influenced by untrusted code. The follow-up should read only narrowly defined and validated data; never execute an upstream artifact as a trusted script.

Set an explicitly read-only token for test workflows

Set the default GITHUB_TOKEN permissions for GitHub Actions to read-only in repository settings. Then declare the precise permissions needed by each workflow or job. This prevents a new workflow from accidentally inheriting broad write access.

This workflow runs CI checks without writing to the repository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
name: CI

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - name: Run tests
        run: ./scripts/test.sh

contents: read allows the job to read repository content. persist-credentials: false prevents checkout from persisting the token in the local Git configuration, which is appropriate when tests do not need to push changes. The example uses a readable Action version; for production repositories, pin dependencies to full commit SHAs if that matches your policy, and keep them updated.

If an Action does not need the GitHub API, you can omit job-level permissions after confirming that the repository’s default token permissions are restricted. A read-only workflow default plus narrowly scoped permissions on the few jobs that need writes is easier to audit than contents: write across the entire workflow.

Remove secrets from external PR tests

Tests usually should not depend on production deployment credentials. Split the workflow by purpose: run tests without secrets for external PRs, then deploy after a change reaches a protected branch. Use an Environment approval to control access to deployment credentials.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - name: Authenticate to cloud provider
        run: ./scripts/cloud-login.sh
      - name: Deploy
        run: ./scripts/deploy.sh

If your cloud provider supports OIDC, prefer short-lived identity credentials over long-lived cloud secrets stored in the repository. id-token: write permits requesting an OIDC identity token; it does not grant repository content write access. The cloud provider must still restrict trust to the expected organization, repository, branch, or Environment. Configure audience and subject conditions according to the cloud provider’s documentation, and check both sides of the trust relationship.

Use pull_request_target for PR metadata writes

You can use pull_request_target for metadata automation, provided the job only operates on event metadata such as the PR number, title, or labels. It must not check out or execute PR content, and it must not interpolate untrusted fields into a shell command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
name: Label pull request

on:
  pull_request_target:
    types: [opened, reopened]

permissions:
  pull-requests: write
  issues: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Add triage label
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --add-label "needs-triage"

The token is limited to pull-requests: write and issues: write for label operations. Specific API operations may need different permissions. Check the GitHub API documentation and add only the missing permission instead of granting every permission.

Passing expression results through environment variables and then as quoted arguments avoids inserting contributor-controlled text into shell source. Avoid this pattern:

1
- run: echo "PR title: ${{ github.event.pull_request.title }}"

A contributor controls the PR title. Direct interpolation into a script can cause command injection. Treat even text that is only being printed as untrusted. Use a safe API or a purpose-built Action to display it, and constrain values passed to scripts.

Do not check out PR code in pull_request_target

Do not combine a privileged trigger with a checkout like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
on:
  pull_request_target:

jobs:
  unsafe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test

This checks out an external commit, then runs its dependency installation and test scripts with repository permissions. A malicious dependency script could read environment variables, steal accessible credentials, or make repository write requests. Hiding the token in another step does not fix this: steps in a job share the runner environment, and malicious code may access process environments or files.

persist-credentials: false reduces the risk of leaving checkout credentials in Git configuration. It does not sandbox a privileged workflow or revoke permissions exposed to a process through environment variables. Do not use it to justify checking out and executing untrusted code under pull_request_target.

Split workflows when PR files must be inspected

Some automation needs to inspect a PR patch or build result and then publish a result on the PR. Run the untrusted code first in a secretless, read-only workflow. A separate, restricted post-processing workflow should consume only a defined result.

A safer design is to run tests under pull_request, upload only a small, simple result file, and let a second workflow read it only after validating the conditions. The second workflow must not execute scripts, binaries, or unvalidated serialized objects uploaded by the first workflow.

The following illustrates data-flow separation. It is not a ready-made privilege-escalation design:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
name: PR checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - run: ./scripts/test.sh
      - name: Write summary data
        if: always()
        run: printf '{"result":"%s"}\n' "${{ job.status }}" > result.json
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: check-result
          path: result.json
          retention-days: 1

Artifact name, association with the PR and run ID, provenance, file size, and JSON parsing all require validation. A file is not trusted just because it is named result.json; an upload from a PR workflow can be influenced by contributor code.

If the follow-up uses workflow_run, tightly restrict workflows and types. Confirm that the triggering run belongs to the expected workflow in the target repository, and validate the associated PR number, commit SHA, and artifact source. Minimize permissions, do not check out the upstream PR, and do not execute artifact code in the same job. For high-risk writes, use a maintainer-triggered step or require Environment approval.

If you cannot reliably validate artifact provenance and contents, do not automatically grant write permissions. Keeping CI read-only and having a maintainer publish a comment or merge after checks pass is a reasonable fallback.

Isolate jobs that write and require approval

Do not give every CI step a write token. Split tests, builds, status publication, and deployment into separate jobs. Only the job that needs write access should declare it, and it should depend on prerequisite checks.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - run: ./scripts/test.sh

  publish-status:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      statuses: write
    steps:
      - name: Publish status
        run: ./scripts/publish-status.sh

needs waits for the prerequisite job to succeed, but it is not itself a trust check. Review conditions alongside the trigger and branch protection. Do not let a fork workflow use controlled inputs to fake a trusted-branch check.

For production deployment, configure a production Environment in repository settings with required reviewers, allowed deployment branches, and environment secrets. The deploy job can access those secrets after approval. Environment approval protects access to the environment; it does not isolate untrusted code running in the same job. Never check out a fork PR in a deployment job.

Review third-party Actions and caches

Every Action referenced by uses: runs on the runner. It can access inputs, environment variables, and the token context. Even if YAML does not explicitly pass GITHUB_TOKEN, an Action may access it through the GitHub context. Review Action source and versions, and minimize token permissions.

For critical workflows, pin third-party Actions to full commit SHAs and verify the SHA belongs to the Action’s source repository. Tags such as @v4 are readable but can move. SHA pinning improves reproducibility but requires automation or a maintenance process to keep security updates current. If your repository adopts GitHub’s workflow dependency lockfile, review and update dependencies through that mechanism.

A cache can also carry data across trust boundaries. Do not let a privileged workflow execute cache contents written by an untrusted PR workflow. Do not let an untrusted workflow overwrite a cache later used by a trusted branch. Include lockfiles or trusted commit identifiers in cache keys, validate cache contents after restore, and do not treat a cache as a trusted build artifact.

Pay special attention to shared caches with pull_request_target or workflow_run. GitHub notes that these privileged triggers may share caches with the base branch. If untrusted code poisons a cache later consumed by a privileged workflow, the risk crosses the workflow boundary.

Check the token permissions used by a run

After merging a configuration change, do not infer effective permissions from YAML alone. Trigger the workflow on a test branch and review the permissions shown in the run. You can also add a step that reports only the required metadata; never print the token itself.

For example, use GitHub CLI to inspect repository metadata without exposing the credential:

1
2
3
4
5
6
7
8
9
- name: Inspect repository and event
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    REPOSITORY: ${{ github.repository }}
    EVENT_NAME: ${{ github.event_name }}
  run: |
    printf 'Repository: %s\n' "$REPOSITORY"
    printf 'Event: %s\n' "$EVENT_NAME"
    gh api "repos/$REPOSITORY" --jq '{full_name, visibility}'

This request verifies read access without displaying the token. To verify a write operation, test the corresponding API in a test repository and inspect the response under the minimum permissions. Do not permanently broaden production permissions to make a diagnostic step pass.

GitHub Actions CodeQL analysis or OpenSSF Scorecard can also flag risky workflow patterns, token permissions, and unpinned Actions. Static analysis can identify risks, but it cannot replace a review of whether the workflow executes untrusted code or consumes untrusted artifacts.

Review checklist

Before merging a workflow change, check each item:

  • Do fork PR tests use pull_request without production secrets?
  • Are default GITHUB_TOKEN permissions read-only, with job permissions scoped separately?
  • Does pull_request_target handle metadata only, without checking out or running PR code?
  • Are PR titles, branch names, and commit messages passed through environment variables or safe API parameters rather than inserted into shell source?
  • Does workflow_run validate the upstream workflow, run ID, PR, and commit SHA?
  • Does a post-processing job read only constrained, validated data without running uploaded files?
  • Are third-party Actions reviewed and pinned to a version or commit SHA?
  • Could a self-hosted runner execute a workflow from an untrusted branch?
  • Does a cache cross the boundary between untrusted and privileged workflows?
  • Are write permissions and environment secrets available only to jobs that need them?

Protect the workflow files themselves

Workflow definitions live in .github/workflows/. If outside contributors can change these files and maintainers automatically merge based only on passing checks, a PR may bypass the permission design. Add CODEOWNERS for the workflow directory and require maintainers to review triggers, permissions, Action sources, expressions, and runner selection.

1
2
# .github/CODEOWNERS
/.github/workflows/ @your-org/security-reviewers

Replace the example team with an account or team that can perform the review. Confirm branch protection requires Code Owner review and limits who can bypass it. Adding CODEOWNERS without enabling the corresponding protection rule does not require approval by itself.

Also review action.yml, reusable workflows, composite Actions, and build scripts. A workflow may appear to run only npm test, while the repository’s package.json maps that command to an arbitrary script. Trace the full execution chain instead of checking only for obvious curl or bash commands in YAML.

Restrict event metadata to the expected repository and branch

If automation targets PRs against the default branch, list the base branch explicitly and verify that the event context comes from the expected repository. Branch filters reduce accidental runs; they do not create a permission boundary. An external PR remains untrusted code and must not run in a privileged workflow.

1
2
3
4
on:
  pull_request_target:
    branches: [main]
    types: [opened, reopened, labeled]

Keep event types narrow as well. If a label is needed only when a PR is opened, there is no reason to rerun a privileged workflow for every synchronization, close, or comment event. Fewer triggers reduce opportunities for mistakes and make audit records easier to understand.

When checking github.event.pull_request.head.repo.full_name, account for the difference between same-repository branches and fork PRs. The field can be empty, for example after a source branch is deleted. Fail safely when required fields are missing rather than falling back to broad permissions. Treat every string from the event JSON as data; do not concatenate it into shell commands or file paths.

If a workflow should operate only on one repository, also verify that base.repo.full_name matches the current repository. For reusable workflows, inspect caller-supplied inputs and permission declarations rather than assuming the caller is trusted. If needed, keep an allowlist in protected default-branch configuration and review changes to it.

Before posting a comment or label, validate that the PR number is a positive integer, the repository name matches owner/repo, and the label is in a fixed set. Stop the workflow on validation failure and record a reason without sensitive data. Fixed sets for labels and command options prevent event strings from being interpreted as CLI arguments.

Review run history and permission changes

Security review should not happen only during initial setup. Periodically review Actions run history, especially new write permissions, secret usage, third-party Action upgrades, pull_request_target triggers, and runner-label changes. For an unexpected run, pause related write operations, then check the event, commit SHA, Actions used, and log output.

If a credential appears in a log, delete or restrict access to the log and rotate the credential immediately. Automatic masking is not guaranteed: encoded, concatenated, or truncated secrets may not match the original value. Never copy a real token into a debug command, PR comment, or ticket. Reproduce data-flow issues in a temporary test repository with a placeholder that has no permissions.

Teams that jointly review privileged workflows can record the purpose of a change, why each token permission is needed, which inputs are untrusted, whether PR code is checked out, and how to roll back. If permissions are misconfigured, first revoke unnecessary write access or disable the workflow, then decide whether tokens and environment secrets need rotation.

Common failures and how to handle them

A fork PR workflow cannot read a Secret

This is GitHub’s protection for untrusted external contributions. Do not put the Secret in the workflow file or change the test workflow to pull_request_target and run PR content. Move the credential-dependent step to a later workflow on a protected branch, or provide test configuration without sensitive data.

Adding a label to a PR returns 403

Check whether permissions includes the required pull-requests: write or issues: write permission, and whether repository or organization policy further restricts Actions. Add only the permission explicitly required by the API. Do not use a fully privileged token to troubleshoot.

A push made by Actions does not start the next workflow

Most repository write operations performed with GITHUB_TOKEN do not start a new workflow run, to prevent recursive triggers. Revisit designs that depend on automatic chaining. If additional permissions are necessary, consider an explicitly authorized GitHub App and assess recursion, permission, and audit risks.

Do not replace the token with a personal access token just to bypass this behavior. A PAT often has a broader scope, a longer lifetime, and is tied to a personal account. If you need additional access, consider a GitHub App installation token scoped to the required repositories and permissions.

Organization policy prevents write permissions

Repository workflow permissions cannot override an organization-level policy. Check organization Actions settings, repository policies, and branch protection to understand the effective permissions. An administrator should make any required change at the proper level; do not try to bypass the restriction by switching triggers.

Running external PRs on a self-hosted Runner

A self-hosted Runner may retain its workspace, caches, network connectivity, or other credentials between jobs. Especially for public repositories, do not run untrusted PRs on a Runner that can reach internal networks or long-lived secrets.

Prefer ephemeral GitHub-hosted Runners. If self-hosting is necessary, isolate Runner groups, restrict which repositories and workflows can use them, remove unrelated credentials and internal network access, and clean the environment after each job. Deleting only the workspace does not guarantee that the system is trustworthy again.

Choose a trigger based on the work

Goal Suggested trigger Token scope Avoid
Test an external PR pull_request Read-only contents Injecting production secrets, persisting Git credentials
Label or comment on a PR pull_request_target Only required issue/PR permissions Checking out PR head, running PR scripts
Deploy a trusted branch push to a protected branch Read-only contents plus required OIDC permissions Running unreviewed PR code in the deploy job
Summarize an earlier workflow Strictly validated workflow_run Minimum write permissions Executing upstream artifacts or trusting shared caches

If an automation needs both to run contributor code and write to the target repository, split it into two trust stages. The first stage performs only the tests required for untrusted code. The second performs metadata writes or deployment only under controlled conditions. Keep data passed between stages small, verifiable, and parsed as untrusted input.

References