GitHub Actions · intermediate

GitHub Actions fails only on fork pull requests: inspect token permissions

A playbook for diagnosing GitHub Actions runs that succeed on the base repository but fail specifically on pull requests opened from forks. It walks through recognizing GITHUB_TOKEN permission downgrades, separating permission failures from secret-unavailable failures, and choosing an evidence-based remediation path without weakening repository security.

The symptoms

  • Workflow passes on push to a branch in the base repository but fails on pull_request opened from a fork with errors citing missing scopes on GITHUB_TOKEN.
  • Job log shows Resource not accessible by integration, Permission denied to <gh-username>/<repo>, or 403 from the GitHub API when calling contents, issues, or pull-requests endpoints.
  • Steps that depend on repository secrets such as NPM_TOKEN, CODECOV_TOKEN, or deploy keys silently produce empty values or fail with Bad credentials when run from a fork pull_request trigger.
  • Approval gate "Waiting for approval" appears next to workflow runs from forks even though the workflow file defines no environment protection rules.
  • Third-party actions report "Not allowed to use this action" or "Action is not allowed to be used in this workflow" only on fork PR runs, while same actions run fine on internal branches.

Likely causes

  • The default GITHUB_TOKEN is downgraded to read-only for fork pull_request events per GitHub's workflow security model, so any write-capable step now lacks the required permission.
  • Repository or organization secrets are intentionally not exposed to fork pull_request workflows, so steps that depend on those secrets cannot authenticate.
  • Workflow permissions are set to Read repository contents and packages at the repository or organization level, overriding any explicit permissions block in the workflow file.
  • The forked repository changed a workflow file relative to the base, causing GitHub to treat the run as a "first-time contributor" event requiring manual approval before secrets or tokens are issued.
  • Allow-listed actions or reusable workflows policy at the organization level blocks a third-party action from running on fork-sourced runs even though it is allowed for trusted runs.

First ten minutes

  1. 01Open the failed run in the Actions tab and capture the branch label, event type (pull_request, pull_request_target), and the head repository owner to confirm the trigger originated from a fork.
  2. 02Inspect the run banner; look for "This workflow run was triggered by a pull request from a fork" or "Waiting for approval" indicators that explicitly mark fork-sourced runs.
  3. 03Diff the permissions block in the workflow file against the repository Settings ▸ Actions ▸ General ▸ Workflow permissions to see whether the effective token scope is narrower than the workflow requests.
  4. 04Check Settings ▸ Secrets and variables for repository-level secret scopes; secrets are not passed to fork pull_request workflows unless explicitly opted in via "Require approval for first-time contributors" being bypassed or via pull_request_target.
  5. 05Decide whether the failing step genuinely needs write access or whether it only reads data; if it only reads, a narrower permissions block usually resolves the symptom without changing trust rules.
  6. 06Decide whether the failing step belongs in a separate workflow triggered by pull_request_target that runs from the base branch, so untrusted code cannot influence the privileged context.

Evidence to collect

  • Full job log including any GITHUB_TOKEN scope warnings, "Resource not accessible by integration" lines, or 4xx responses from api.github.com calls.
  • Effective permissions: the merge of the workflow's permissions block and the repository's Workflow permissions setting, plus any organization-level override.
  • Trigger matrix: which events (push, pull_request, pull_request_target, workflow_run) run which job, and which of those events originate from a fork.
  • Secret exposure map: which secrets are required by each step, which scope (repo, environment, organization) they live in, and whether that scope is exposed to fork pull_request events.
  • Action allow-list status: whether each third-party action is permitted at the repository or organization level for fork-sourced runs.

Where to look

  • Repository Settings ▸ Actions ▸ General ▸ Workflow permissions and the adjacent "Allow GitHub Actions to create and approve pull requests" toggle.
  • The failed run's Metadata block and the YAML header of the offending workflow, specifically the on: triggers and the top-level permissions: key.
  • Organization Settings ▸ Actions ▸ General ▸ Policies for "Approval for running workflows from fork pull requests" and the actions allow-list.
  • Settings ▸ Secrets and variables ▸ Actions, noting which secrets are declared at repository vs. environment vs. organization level and whether they are bound to an environment with protection rules.
  • The pull request's checks panel, specifically any "first-time contributor" or "pending approval" annotation that gates secret issuance.

Diagnostic steps

  1. 01Reproduce the failure on an internal branch by re-running the same job after temporarily changing on: to push; if it succeeds there, the fork trust rule is the discriminating factor, not the code.
  2. 02Add a read-only diagnostic step (such as env with no secrets, or actions/github-script with a contents get read call) to print the scopes returned by the API response headers and confirm the token is read-only.
  3. 03Compare the workflow's declared permissions: against Settings ▸ Actions ▸ General ▸ Workflow permissions; the lesser of the two determines the effective GITHUB_TOKEN scope.
  4. 04Audit each step for whether it must write; classify steps into read-only, write-needed-but-safe, and write-needed-privileged. The first group tolerates fork runs; the second may tolerate them with explicit scopes; the third must move to a trusted event.
  5. 05Check whether the failing step consumes a repository or organization secret; if so, record whether the secret is exposed to fork PRs (it normally is not) and whether moving it to an environment with required reviewers changes the answer.
  6. 06Inspect any third-party action referenced in the run for allow-list rejection messages; consult Organization Settings ▸ Actions ▸ General ▸ Policies to confirm allowed actions and reusable workflows.
  7. 07Decide remediation branch: tighten permissions on read-only steps, elevate only steps that need write access, and move privileged steps behind pull_request_target on the base branch where the workflow file is already trusted.

Common mistakes

  • Assuming granting "Read and write permissions" at the repository level will fix fork PR runs; fork runs still do not receive most secrets, so write scope alone does not restore failed secret-dependent steps.
  • Switching the trigger to pull_request_target globally to regain token power without separating untrusted code from privileged steps, which lets fork contributors influence jobs that hold secrets.
  • Hard-coding a long-lived personal access token (PAT) into workflow files or fork PR environment variables to bypass the trust rule, which leaks credentials through fork pull request logs.
  • Assuming organization secrets are exposed to fork PRs because they are visible in the repository's secret list; they are not, unless explicitly configured via pull_request_target on the base branch.
  • Treating "Waiting for approval" as a transient UI state and re-running the job; it is a deliberate first-time-contributor gate that will recur for any new fork contributor until policy changes.
  • Loosening the organization actions allow-list to include unverified third-party actions just to satisfy fork runs, which broadens the supply-chain surface for trusted runs as well.

Safe fixes

  • Tighten the workflow's permissions: block to the minimum scopes actually required (for example contents: read, pull-requests: write only for steps that comment or label) and document each scope's purpose in a comment.
  • Split the workflow into two files: one with on: pull_request that runs read-only checks such as linting and unit tests from the fork HEAD, and one with on: pull_request_target that runs from the base branch using checkout of the PR ref with explicit safe-output filtering.
  • When a step legitimately needs secrets, route it through pull_request_target on the base branch and never echo the secrets into logs; use masked input variables and action expressions that gate output on trusted values only.
  • If the only failure is a missing third-party action, request that the action be added to the organization allow-list rather than inlining its source or substituting an unmaintained fork.
  • Use GitHub's documented workflow syntax fields rather than scripting workarounds: the official workflow syntax for GitHub Actions defines how permissions, on, and environments interact for fork trust rules.

Prove the fix

  1. 01Open a pull request from a throwaway fork repository (or use a test account's fork) and confirm the previously failing step now completes without permission errors and without exposing any secret values in the log.
  2. 02Re-run the same workflow on an internal branch and confirm the behavior is unchanged: read-only steps still pass, write steps still execute against the base repository only.
  3. 03Inspect the run's effective token scopes by adding a read-only diagnostic step that calls api.github.com and records the X-OAuth-Scopes header; verify it matches the workflow's declared permissions block.
  4. 04Confirm the privileged workflow does not checkout the fork ref into a context that executes contributor-controlled code, and that any comment, label, or status write uses sanitized inputs rather than raw PR body text.
  5. 05Remove the diagnostic step after verification so it does not become a permanent logging surface; the regression check is reproducible from the documented split between on: pull_request and on: pull_request_target.

Prevention and next steps

  • Adopt a baseline permissions: block at the top of every workflow that grants only the scopes the job actually uses, and review it whenever a step is added or changed.
  • Establish a repository template that separates fork-safe jobs (triggered by pull_request) from trusted jobs (triggered by pull_request_target or workflow_run) so new contributors inherit a safe default.
  • Periodically audit Organization Settings ▸ Actions ▸ General ▸ Policies so the actions allow-list and fork approval rule reflect current security requirements rather than legacy defaults.
  • Document which secrets may appear in fork-visible logs and which must remain restricted, and add a CODEOWNERS rule for workflow files so permission changes require review.

Safe commands and checks

gh run list --workflow <workflow-name-or-id> --limit 20
gh run view <run-id> --json event,headBranch,displayTitle,conclusion,url
gh run view <run-id> --log
gh api repos/<owner>/<repo>/actions/permissions/workflow
gh api repos/<owner>/<repo>/actions/runs/<run-id>/jobs --jq '.jobs[].conclusion'
gh workflow view <workflow-name-or-id> --yaml
gh secret list --repo <owner>/<repo>