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
- 01Capture the exact failing step name and the literal log line containing "permission", "denied", "403", "forbidden", or "Bad credentials".
- 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.
- 03Open the Actions run summary and check for a red annotation that names the missing permission or scope.
- 04Read the top of the workflow YAML for a top-level permissions: key and every job-level permissions: override beneath it.
- 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
- 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.
- 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.
- 03Diff the workflow YAML against the last known-good version on the default branch to surface any recent permissions: change.
- 04For PAT-based failures, confirm the granted scopes in the developer settings page without exposing the token value in logs.
- 05For OIDC failures, align the workflow's id-token: write value with the trust policy's audience and subject claim patterns.
- 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
- 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.
- 02Issue the same authenticated read used during diagnosis and observe a 2xx response, demonstrating the scope is now granted.
- 03Compare the workflow's effective permissions summary in the run page against the failing step's documented requirement and confirm a match.
- 04For OIDC, the receiving service logs a successful token exchange whose audience and subject match the workflow's id-token permission.
- 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