GitHub Actions · intermediate

GitHub Actions permission denied: inspect the execution boundary

When a CI job fails with a permission error, the boundary to inspect is whether the token or runner process actually carries the rights the step assumes. This guide separates token-level authorization (GITHUB_TOKEN, PAT, OIDC) from runner-level capability (filesystem, network, self-hosted restrictions) using only log and configuration evidence.

The symptoms

  • Workflow step log contains "Resource not accessible by integration", "403 Forbidden", or "401 Bad credentials" from api.github.com.
  • Job aborts before the expected git or gh operation, with no application-level stack trace preceding the failure.
  • Re-running the same commit produces an identical failure, ruling out flake and pointing at a configuration boundary.
  • The Actions run summary shows a red annotation banner naming the failing scope, in addition to any expanded log message.
  • Steps that previously succeeded on the default branch begin failing after a change to workflow permissions, branch protection, or repository token policy.

Likely causes

  • The workflow omits a top-level permissions: block and the repository default for GITHUB_TOKEN is read-only, while the failing step requires write.
  • A job-level permissions: override narrows the workflow grant below what the step consumes (for example, dropping contents: write).
  • A personal access token is used in place of GITHUB_TOKEN but lacks a required OAuth scope such as repo, workflow, or write:packages.
  • The step targets a protected branch, tag, or environment whose bypass policy excludes the current actor or token.
  • An OIDC token is requested but the receiving trust policy rejects the audience or subject claim declared in the job's id-token permission.
  • A self-hosted runner process lacks an OS-level capability the step requires, such as file ownership on the workspace or network egress to an internal endpoint.

First ten minutes

  1. 01Capture the exact failing step name and the literal log line containing "permission", "denied", "403", "forbidden", or "Bad credentials".
  2. 02Note whether the failing call targets api.github.com, a git remote, the gh CLI, or an external service, since this identifies which credential is in play.
  3. 03Open the Actions run summary and check for a red annotation that names the missing permission or scope.
  4. 04Read the top of the workflow YAML for a top-level permissions: key and every job-level permissions: override beneath it.
  5. 05Confirm the repository Settings → Actions → General → Workflow permissions value as either read-only or read-and-write for GITHUB_TOKEN.

Evidence to collect

  • Run URL, run ID, and the failing job ID from the Actions UI, plus the triggering commit SHA.
  • The full workflow file path and contents of the permissions: block at both workflow and job level, including all sub-scopes.
  • The credential identifier used by the failing step, whether an env var, an action input, or the implicit GITHUB_TOKEN.
  • Any branch protection rule, environment protection rule, or required status check applied to the target ref or environment name.
  • For OIDC, the trust policy file's audience and subject claim patterns alongside the workflow's id-token permission setting.

Where to look

  • .github/workflows/*.yml in the triggering commit, especially the permissions: and jobs.<id>.permissions: sections.
  • Repository Settings → Actions → General → Workflow permissions, including "Allow GitHub Actions to create and approve pull requests".
  • Repository Settings → Environments → <env-name> when a deployment job fails and an environment gate exists.
  • Repository Settings → Branches → Branch protection rules, including required reviewers and required status checks.
  • Organization-level settings for shared GITHUB_TOKEN policy, PAT SSO enforcement, and allowed actions allow-lists.

Diagnostic steps

  1. 01Classify the error origin by the failing call: api.github.com maps to token scope, git push maps to branch policy or token write, file write maps to runner filesystem, network call maps to egress policy.
  2. 02For an API-originated 403, replace the failing action with a minimal authenticated read using the same credential and observe the response status and message.
  3. 03Diff the workflow YAML against the last known-good version on the default branch to surface any recent permissions: change.
  4. 04For PAT-based failures, confirm the granted scopes in the developer settings page without exposing the token value in logs.
  5. 05For OIDC failures, align the workflow's id-token: write value with the trust policy's audience and subject claim patterns.
  6. 06For self-hosted runners, run id against the workspace and ls against any path the step claims to lack, scoped to the runner user.

Common mistakes

  • Assuming GITHUB_TOKEN inherits write access, when many repositories default to read-only at the organization or repository level.
  • Editing a workflow file in a fork or feature branch that does not carry the expected permissions block from the base workflow.
  • Treating a 403 as transient when the response body names a specific missing scope or permission key.
  • Conflating repository visibility (public or private) with token capability; visibility is unrelated to GITHUB_TOKEN scope.
  • Adding a secret to env without first checking the action's documented permission requirements, then blaming secret rotation for an unrelated scope error.

Safe fixes

  • Add an explicit workflow-level permissions: block granting only the scopes the steps require, such as contents: read or contents: write.
  • Narrow to job-level permissions overrides so unrelated jobs keep least privilege while the failing job gains what it needs.
  • For PATs, rotate with the documented minimum scopes rather than reissuing a broad token, and record the scope rationale in the secret description.
  • For OIDC, set permissions: id-token: write only on the job that exchanges the token and align the trust policy's audience and subject claims.
  • For self-hosted runners, adjust file ownership or network policy at the runner host without writing secrets into the runner configuration.

Prove the fix

  1. 01Re-run the failing job from the Actions UI and confirm the previously failing step completes with no 403 or "Resource not accessible" line in its log.
  2. 02Issue the same authenticated read used during diagnosis and observe a 2xx response, demonstrating the scope is now granted.
  3. 03Compare the workflow's effective permissions summary in the run page against the failing step's documented requirement and confirm a match.
  4. 04For OIDC, the receiving service logs a successful token exchange whose audience and subject match the workflow's id-token permission.
  5. 05For self-hosted runners, the step's filesystem or network call returns a success exit code in a follow-up job on the same runner.

Prevention and next steps

  • Pin permissions: at the top of every workflow and require review of diffs to .github/workflows in pull requests.
  • Prefer GITHUB_TOKEN with the narrowest scope over PATs wherever the chosen action supports automatic token authentication.
  • Add a smoke step early in each job that performs a cheap authenticated read, so a scope regression fails fast rather than at the end of the job.
  • Document the required token scopes in a header comment of each workflow so reviewers can spot permission mismatches before merge.

Safe commands and checks

gh run view <run-id> --log-failed
gh workflow view <workflow-file-or-id>
gh api user
gh auth status
grep -nE '^permissions:|^  permissions:' .github/workflows/<file>.yml
gh run view <run-id> --json jobs,conclusion