GitHub Actions Permissions and Untrusted Pull Requests

Set explicit Actions token permissions, understand fork pull-request trust and keep privileged metadata automation separate from executing contributor code.

Lesson 26 of 30Intermediate10 min read

An Actions job has both a capability boundary and a code-trust boundary. Token permissions control which API operations its token can perform. The event, checkout and commands determine whose code runs with that access. A workflow can use a narrowly scoped token and still be unsafe if it executes an untrusted script near a separate credential. This lesson builds a metadata-only demonstration and an executable local capability exercise. It helps you review a workflow by tracing data, code and credentials rather than trusting an event name or a green status icon.

Give each job the capabilities it needs

GITHUB_TOKEN is provided for workflow authentication, with permissions affected by repository or organization defaults, the event and workflow settings. Declare permissions explicitly instead of assuming a default is always read-only or always writable. At a scope where you specify individual permissions, unspecified permissions become none. Start with an empty set when a job needs no repository API access, then grant the exact capability required by a concrete operation.

Job-level settings can narrow or specify capabilities for that job within the platform's applicable limits. A report reader and a package publisher should not automatically receive the same access. When reviewing a workflow, list the operations each job performs and connect them to the declared permissions. A requested permission is not a guarantee that every event will receive it: fork restrictions and administrative settings also matter. Inspect the documented effective behavior for your repository rather than assuming a YAML write setting overrides all other constraints.

Treat fork contributions as untrusted code

A pull request can modify tests, build scripts, package hooks and dependencies, not just the application source you intended to review. Running those commands executes contribution-controlled code. Use a suitable isolated runner and avoid supplying secrets to an untrusted test job. Fork pull requests normally receive restricted token access and do not receive ordinary repository secrets, but special settings and event behaviors can alter the situation. Keep those exceptions visible in the policy.

A hosted temporary runner reduces persistence concerns compared with a long-lived self-hosted machine, but it is not a reason to provide unnecessary credentials. Think about network access, stored files and what a malicious test could read. The repository's code-review step remains important even when CI passes. A workflow that invokes npm install, a Makefile or a test executable is executing code; labels such as build-only do not make those commands passive.

Understand the pull_request_target boundary

pull_request_target runs in the base repository's context and can support trusted metadata operations on incoming requests. That context is useful for tasks such as assigning a label, but it becomes dangerous if the job checks out the contributor's head and executes its scripts with privileged access. The distinction is the executed code and available credentials, not whether the workflow file itself came from the base branch.

Keep metadata automation narrowly focused. Read a request number or title as data, validate it for the API operation and avoid constructing shell source from it. If an operation genuinely requires executing contributor code, design a separate isolated validation path with an appropriate trust contract. Do not download an untrusted build artifact and execute it in a privileged follow-up job simply to avoid checking out the head directly. The artifact can carry the same untrusted code boundary through a different channel.

Review action pins, caches and persistent state

Third-party actions execute code too. A full commit pin fixes the implementation being requested, while a mutable tag or branch can move. Review pinned updates and their permissions deliberately. A pin does not make an action's behavior inherently safe or validate its inputs. Also consider whether the action persists credentials in a checkout, uploads broad directories or invokes project-controlled scripts.

Caches, artifacts and self-hosted runners can carry state between jobs or trust levels. Never cache credentials or assume a file is trusted merely because it came from a workflow. Validate the source run, repository, revision and expected content before privileged consumption. For self-hosted runners, establish isolation and cleanup that match the contribution trust level; a machine with production access is not an appropriate place to execute arbitrary public fork code. Use a concrete threat scenario to test the workflow's boundaries instead of adding unrelated restrictions blindly.

Sources and prerequisites

GitHub Actions Permissions and Untrusted Pull Requests Summary

  • Declare permissions per concrete job responsibility.
  • Treat fork scripts, hooks and executables as untrusted code.
  • Review privileged events and artifacts by tracing what actually executes.

GitHub Actions Permissions and Untrusted Pull Requests Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Inspect pull-request metadata without executing its code

Save this complete demonstration as .github/workflows/pr-metadata.yml. It handles pull_request events, requires no token permission and does not check out the contribution. With a branch named feature/demo, the expected final lines are shown. This is a hosted-workflow prediction, not a live run performed by this lesson.

The branch value is passed through env and quoted in a fixed command. The workflow has no checkout, package installation or contribution-controlled executable. It is therefore useful as a small data-boundary demonstration, not as an application test or a complete repository-security policy.

YAML
name: Pull request metadata demo
on:
  pull_request:
    types: [opened, synchronize, reopened]
permissions: {}
jobs:
  describe:
    runs-on: ubuntu-latest
    steps:
      - name: Print bounded metadata as data
        env:
          HEAD_BRANCH: ${{ github.head_ref }}
        run: |
          test "${#HEAD_BRANCH}" -le 200
          printf 'branch: %s\n' "$HEAD_BRANCH"
          printf 'metadata inspected\n'
Output
branch: feature/demo
metadata inspected

Common GitHub Actions Permissions and Untrusted Pull Requests Mistakes

Running fork code in a privileged target-event job

A base-branch workflow can still execute untrusted code if it checks out a contributor's head, runs an uploaded executable or invokes an untrusted package hook. Trace the code source at every execution step and keep privileged metadata operations separate from that execution boundary.

GitHub Actions Permissions and Untrusted Pull Requests in Real Projects

Model least-privilege token permissions per job

Run this standalone Node.js exercise locally. It models an explicit capability allowlist for two hypothetical jobs. It does not calculate GitHub's actual token permissions; real permissions still depend on the workflow, event and repository settings.

JavaScript
const jobs = {
  inspect: new Set(),
  label: new Set(['issues:write'])
};
const allowed = (job, operation) => jobs[job].has(operation);
console.log('inspect can label:', allowed('inspect', 'issues:write'));
console.log('label can label:', allowed('label', 'issues:write'));
console.log('label can publish:', allowed('label', 'packages:write'));
const headBranch = 'feature/demo';
console.log('branch data:', headBranch);
Output
inspect can label: false
label can label: true
label can publish: false
branch data: feature/demo

Each modeled job gets only its stated capability. Adding label access does not automatically grant package publishing. For an actual automation job, map the intended API call to its documented permission, then test the effective permissions on the relevant event and repository.

GitHub Actions Permissions and Untrusted Pull Requests FAQ

Does a read-only GITHUB_TOKEN make an untrusted test harmless?

No. The token's capabilities are only one part of the job's environment. The code may read other credentials, contact external services, modify persistent runner state or influence files consumed by a later privileged job. Use suitable isolation and avoid unrelated secrets. Review the code source and downstream artifacts as well as token permissions. A limited token reduces exposure but does not turn arbitrary code into passive data.

Why should pull_request_target not run the contributor's scripts?

That event operates in the base repository's context and may have access intended for trusted automation. Checking out or otherwise executing the contributor's code crosses the trust boundary while retaining that context. Keep its use to narrowly reviewed metadata operations when appropriate, and process user-controlled values as data. Use a separate isolated validation workflow for untrusted code instead of relying on the base origin of the workflow file.

Can a job grant itself write access on every fork event?

Requested YAML permissions are subject to event and repository or organization restrictions. Normal fork pull-request behavior restricts access, and administrative options or different events can change the effective result. Consult the current documentation and test your repository's settings. Do not assume that writing a permission name guarantees the token receives it, and do not weaken fork restrictions simply to make an unrelated CI command succeed.

Why are artifacts part of a workflow security review?

A file produced by untrusted code remains untrusted when another job downloads it. If a privileged consumer executes that file or interprets it as a script, the trust boundary has moved through the artifact channel. Verify provenance, expected format and content, and avoid executing arbitrary contributed output with credentials. Naming an artifact report or build does not establish that its bytes are safe for a privileged consumer.

GitHub Actions Permissions and Untrusted Pull Requests Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

Why can the modeled label job not publish a package?

GitHub Actions Permissions and Untrusted Pull Requests Quiz

2 questions to check what you learned.

  1. What is the key risk of executing a fork's head in a privileged pull_request_target job?

    • The branch automatically becomes private
    • Untrusted code can run with the job's trusted-context access
    • The workflow loses all ability to read metadata
  2. Which review best describes a workflow's trust boundary?

    • Read only the workflow display name
    • Assume every green check used trusted code
    • Trace code sources, credentials, permissions and downstream files