GitHub Actions · beginner
GitHub Actions permission checklist
A beginner playbook for verifying GitHub Actions permissions when a workflow step fails because it lacks a token or runner permission. It walks engineers from the first error line through reading the workflow YAML, mapping the GITHUB_TOKEN scopes, checking job-level and workflow-level permissions, and confirming the runner context, then closes with a re-run that proves the fix.
The symptoms
- •Workflow run fails with messages such as "Resource not accessible by integration", "403 Forbidden", or "Bad credentials" appearing on a single step while earlier steps in the same job succeed.
- •git checkout, push, or tag operations inside an action report "Permission denied" or "Authentication failed" even though the repository is reachable and the runner is online.
- •Third-party actions such as release publishers, package publishers, or cloud deployers return HTTP 401 or 403 with the GitHub API URL visible in the log output.
- •Step output contains the phrase "GITHUB_TOKEN has been deprecated" or "needs to be allowed to" indicating a tightened default permission policy.
- •Re-running a previously green workflow after a repository settings change or an org-wide policy update now fails at the same step with no code changes.
Likely causes
- •The workflow file or repository settings set the default GITHUB_TOKEN permissions to read-only, and the failing step requires write access for contents, packages, issues, pull-requests, or deployments.
- •The job specifies permissions: less explicitly, narrowing the token scope below what the steps in that job actually need (for example, contents: read on a job that needs to push a commit).
- •The workflow depends on a personal access token or GitHub App installation token that has expired, been revoked, or lacks the scopes required by the action, and the step silently falls back to no token.
- •An organization or enterprise policy enforced "Allow GitHub Actions to create and approve pull requests" or repository creation restrictions, blocking the token even though the workflow YAML looks correct.
- •The step is running on a self-hosted runner whose groups, labels, or service account cannot reach the GitHub API endpoint or has lost its registration token, so the runner appears healthy but cannot authenticate outbound calls.
First ten minutes
- 01Step 1: Open the failed run in the Actions tab and read the exact failing step name and the first error line; record the HTTP status, the API URL, and whether the message mentions GITHUB_TOKEN, a PAT, or an installation token.
- 02Step 2: Capture the workflow file path, the triggering event, and the branch or ref so you can reproduce the run deterministically rather than reasoning from the UI summary.
- 03Step 3: Resolve the workflow file from the commit SHA recorded in the run header and locate the job that contains the failing step, then read its permissions: block and the step-level env: and with: entries.
- 04Step 4: Check the repository Settings under Actions, General, Workflow permissions to see whether the default token permission is read-only or read-and-write, and whether "Allow GitHub Actions to create and approve pull requests" is enabled.
- 05Step 5: If the failing step uses a custom token, open the secret's definition page (without revealing the value) and confirm the secret still exists, has not been rotated, and is bound to the correct environment.
- 06Step 6: Skim the run log lines immediately before the failure for a "missing permissions" or "needs to be allowed" note, which is the most direct signal that the YAML or org policy is the source.
Evidence to collect
- •The exact step name, the first error line, and the HTTP status or API URL exposed in the log; these identify which permission boundary the call crossed.
- •The job's permissions: block and the workflow-level permissions: default, captured verbatim from the YAML at the failing commit SHA.
- •The repository-level "Workflow permissions" setting and any organization-level policy that overrides repository defaults.
- •For non-default tokens, the secret name referenced in the step, plus the scopes or fine-grained permissions granted to the PAT or GitHub App that issues the token.
- •For self-hosted runners, the runner's labels, group membership, and the timestamp of its last successful job registration with the GitHub Actions service.
- •The triggering event (push, pull_request, workflow_dispatch, schedule) and the branch or tag, since permission scope and write access often differ per event type.
Where to look
- •Boundary 1: the workflow YAML at the commit SHA pinned by the failing run, focusing on the top-level permissions: and the per-job permissions: block, then each step's env: and with: mappings.
- •Boundary 2: the repository Settings page under Actions, General, Workflow permissions, where the default GITHUB_TOKEN scope and the "create and approve pull requests" toggle live.
- •Boundary 3: the Secrets and variables configuration for the repository and any referenced environment, since custom tokens are injected from this boundary and can be expired or scoped incorrectly.
- •Boundary 4: the organization's Actions policy and any enterprise-wide policy that restricts token scopes, repository creation, or which actions are allowed.
- •Boundary 5: the self-hosted runner registration and runner group configuration when the runner type is not github-hosted, including the service account that runs the runner process.
- •Boundary 6: the run log itself, specifically the lines preceding the failure, where GitHub Actions emits structured permission warnings about the token scope.
Diagnostic steps
- 01Match the error message to a permission boundary: 403 on the contents API maps to contents, 403 on packages maps to packages, 403 on deployments maps to deployments, and so on; this narrows which YAML scope to change.
- 02Diff the failing workflow YAML against a previously green run of the same workflow on the same branch family to identify any permissions: change, secret rename, or step that no longer matches the default scope.
- 03Reconstruct the effective token scope by taking the intersection of the workflow-level permissions, the job-level permissions, and the repository default; an empty intersection explains most "token has no scopes" failures.
- 04Confirm whether the failing step expects the default GITHUB_TOKEN or a custom secret; if env: TOKEN_NAME is set, the step is bypassing the default token and the secret's definition is the next place to inspect.
- 05For org or enterprise restrictions, trace the token's effective permissions from the most restrictive policy downward; the failing step must satisfy both the local scope and the inherited policy.
- 06For self-hosted runners, verify the runner is listed as "Idle" (not "Offline") and that its last heartbeat predates the run, then check that the runner service account can reach the GitHub API endpoint from the runner host.
- 07When the run is a pull_request from a fork, note that GITHUB_TOKEN is read-only and secrets are unavailable by default; this single rule explains many first-time-contributor failures.
Common mistakes
- •Editing the workflow on the main branch before confirming the failing commit SHA, so the YAML you read no longer matches the YAML that ran.
- •Assuming the default GITHUB_TOKEN is read-and-write when the repository or organization has switched to read-only; the step writes and the token refuses, producing a confusing 403.
- •Setting permissions: at the workflow level but not at the job level, then declaring a job-level permissions: block that overrides it downward without realizing the override is the new minimum.
- •Pointing a step at a personal access token that has been rotated or whose fine-grained permissions were trimmed, then declaring the workflow "broken" without checking the secret definition.
- •Trusting the Actions tab summary instead of the raw log, which hides the exact permission denial message and the granting scope that GitHub suggests.
- •Re-running a workflow from a fork without enabling the "Send write tokens to workflows from fork pull requests" option, so the fix never has a chance to take effect.
Safe fixes
- •Conditional on a default-token failure: in the workflow file, add an explicit permissions: block at the job level listing only the scopes the failing step requires, then commit on a branch and open a pull request so the change is auditable.
- •Conditional on a repository default that is too tight: in Settings under Actions, General, raise the workflow permission to read-and-write, or keep read-only and grant the specific scope in the YAML, which is the more conservative choice.
- •Conditional on a custom-secret failure: rotate the secret, then update the secret's definition with the minimal scope set the action documents, and reference the new secret name in the step's env: mapping without printing the value.
- •Conditional on an organization policy: request the org owner to allow the specific scope for the repository, or add the workflow to an allowlist if the policy restricts repositories or actions.
- •Conditional on a self-hosted runner: re-register the runner with a fresh registration token, confirm the runner group has access to the repository, and verify the service account's outbound network reach to the GitHub API endpoint.
- •Conditional on a fork pull request: keep the workflow read-only on forks and move any write step to a trusted-ref job that runs on push, since secrets and write tokens are gated by event source.
Prove the fix
- 01Re-run the failing workflow on the same branch and trigger event, and confirm the previously failing step now completes with an HTTP 200 or 201 in the log, or with the expected artifact, release, or comment appearing in the repository.
- 02Inspect the re-run log for the absence of any "Resource not accessible by integration", "403 Forbidden", or "needs to be allowed" warning, and confirm the step's exit code is 0.
- 03Cross-check the Actions tab summary: the run should transition from failed to succeeded, and the step timeline should show the previously red step rendered green with the same duration characteristics.
- 04For a permissions: change, diff the workflow file before and after the fix and confirm the new scope is the minimum set that satisfies the step, not a blanket read-and-write that weakens the principle of least privilege.
- 05For a custom-secret change, confirm the secret definition timestamp moved forward and that no other workflows still reference the old secret name, which would silently fall back to the default token.
Prevention and next steps
- •Pin every workflow's permissions: at the job level to the minimum scope set the steps require, and treat the workflow-level default as belt-and-suspenders rather than the source of truth.
- •Keep the repository default GITHUB_TOKEN permission set to read-only and grant scopes explicitly in YAML, so a new step that needs write access fails loudly rather than inheriting broad power.
- •Document each custom token's purpose, owner, and rotation cadence in the repository's security notes, and alert on rotation so workflows keyed to an old secret fail in code review rather than in production.
- •Review the org and enterprise Actions policy before adding a new workflow, especially the allowlist for third-party actions and the policy on secrets in pull requests from forks.
- •For self-hosted runners, monitor the runner's online status and last-heartbeat timestamp, and document the service account and network path required for outbound calls to the GitHub API endpoint.
Safe commands and checks
gh run view <run-id> --repo OWNER/REPO --log --json steps,name,conclusion,number gh workflow view WORKFLOW --repo OWNER/REPO --yaml gh api repos/OWNER/REPO/actions/permissions/workflow --jq '.default_workflow_permissions,.can_approve_pull_request_requests' gh secret list --repo OWNER/REPO gh api repos/OWNER/REPO/contents/.github/workflows/<file>.yml?ref=<commit-sha> --jq '.content' | base64 -d gh api repos/OWNER/REPO/actions/runner-groups --jq '.runner_groups[].name,.runner_groups[].runners[].name,.runner_groups[].runners[].status'