Chapter 06Lesson 04~155 minutes

GITHUB_TOKEN, Workflow Permissions, Least Privilege, and API Access: Diagnostics, Failure Modes, and Production Practices

Authorization failures are often misdiagnosed because the visible symptom—“API call failed”—looks similar whether the cause is insufficient scope, fork downgrade, organization policy, invalid authentication, rate limiting, wrong resource identity, or an ordinary network/runtime defect. This lesson uses preserved HTTP evidence and workflow state to locate the causal layer before anyone broadens a token.

Diagnostics403Fork downgradeRate limitsCredential hygiene

Learning objectives

  • Diagnose 401/403/404/410 and rate-limit evidence without printing credentials or blindly retrying.
  • Recognize permissions: write-all, unnecessary PATs, build-job credentials, and token-bearing logs as design defects.
  • Explain fork/Dependabot downgrade and pull_request_target risk without executing untrusted code under privilege.
  • Separate API authorization from runner/network/action/runtime failures and preserve first-failure evidence.
  • Repair an intentionally under-privileged API call by changing only the required permission scope.

1. Preserve the first denied request before changing permissions

When a write returns 403, keep the original run ID/attempt, workflow/source SHA, event/actor/fork state, job permission block, method/endpoint, API version, status, GitHub request ID, accepted-permission hint, and rate-limit headers. Do not rerun with broader privileges first; that destroys the clean comparison.

run_id: 123456789
attempt: 1
event: workflow_dispatch
job: denied_write
requested_permissions: issues=read
request: POST /repos/OWNER/REPO/issues
status: 403
x-github-request-id: ...
x-accepted-github-permissions: issues=write
repository_effect: none

2. Evidence-first diagnostic sequence

Preserve → classify → repair only the causal layer
flowchart TD
    A[Preserve run / attempt / response] --> B[Confirm event + repo + SHA]
    B --> C[Read workflow + job permissions]
    C --> D{Auth status class}
    D -->|401| E[Token presence / format]
    D -->|403| F[Scope / policy / fork / rate limit]
    D -->|404| G[Resource identity or hidden access]
    D -->|2xx| H[Verify exact repository effect]
    F --> I[Check accepted-permission + rate-limit headers]
    I --> J[Apply smallest permission/policy correction]
    J --> K[Rerun same request]
    K --> H

3. Failure mode: permissions: write-all

write-all may make an error disappear, but it converts diagnosis into privilege escalation. A job that creates an issue does not need write authority over Actions, contents, deployments, packages, pages, statuses, attestations, or other capabilities.

Anti-pattern: “403 → add write-all → green.” The repaired workflow no longer proves which permission was required and gives every script/action in that job broad authority.

Use endpoint documentation and response permission hints to grant the narrow key, then repeat the same request.

4. Failure mode: using a PAT when GITHUB_TOKEN suffices

A PAT can make a same-repository permission failure disappear because it brings a different identity and policy surface. That also changes audit ownership, lifetime, revocation, cross-resource reach, recursion behavior, and secret-storage requirements. It is not a neutral troubleshooting step.

Only move to a GitHub App/PAT when the desired operation genuinely lies outside GITHUB_TOKEN's repository/resource capability or needs a deliberately different trigger identity.

5. Failure mode: assuming a requested write is granted on forks

For normal fork pull-request events, requested writes are typically downgraded to reads unless an administrator has explicitly enabled sending write tokens. Dependabot runs receive fork-like restrictions. A mutation that succeeds on workflow_dispatch may therefore be denied on a fork PR with identical source.

Do not “solve” this by running fork code under pull_request_target with a privileged token. That event executes in the base repository context and can expose powerful authority if untrusted code or artifacts are used unsafely.

6. Failure mode: leaking token-bearing headers

Debugging with curl -v, set -x, whole environment dumps, or copied HTTP transcripts can expose the bearer token. Automatic masking is not a substitute for safe logging, especially after transformations.

# Safe diagnostic shape: do not print Authorization.
status=$(curl -sS -D response.headers -o response.json -w '%{http_code}' \
  -H 'Accept: application/vnd.github+json' \
  -H "Authorization: Bearer $GH_TOKEN" \
  -H 'X-GitHub-Api-Version: 2026-03-10' \
  "$URL")
printf 'status=%s\n' "$status"
grep -i -E '^(x-github-request-id|x-accepted-github-permissions|x-ratelimit-remaining|retry-after):' response.headers || true

7. Failure mode: giving deploy credentials to build jobs

Build/test code frequently processes untrusted inputs and third-party dependencies. If it does not need deployment authority, it should not receive deployment credentials, OIDC issuance rights, package writes, or broad repository writes. Separate build evidence from mutation/deploy authorization into later jobs with explicit dependencies and policy gates.

8. Failure mode: retrying 403 as though it were a flaky network response

A 403 may represent insufficient permission, policy, fork downgrade, primary/secondary rate limiting, or another deliberate denial. Blind retries can worsen rate limiting and hide the true cause.

Evidence Likely interpretation Repair
X-Accepted-GitHub-Permissions: issues=write; job has issues read Insufficient scope Add issues: write only where needed.
Requested write, fork PR event Trust downgrade Redesign trusted mutation boundary; do not privilege untrusted execution.
Rate-limit remaining zero / retry headers Rate limiting Back off according to API guidance; do not broaden permissions.
401 Authentication invalid/absent Check token binding/format without printing it.
404 with private resource Wrong ID or access-hidden resource Verify resource identity and authorization.

9. Intentionally broken example and causal repair

Broken job:

jobs:
  create_lab_issue:
    runs-on: ubuntu-24.04
    permissions:
      issues: read
    steps:
      - name: Create issue
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          # POST /repos/$GITHUB_REPOSITORY/issues
          # Expected first result: 403

Evidence points to issues=write. Repair only that line:

permissions:
  issues: write

Keep event, repository, endpoint, request body, runner and all unrelated settings constant. A new 201 plus exact issue verification demonstrates causal repair.

10. Production diagnostic checklist

  1. Preserve first-failure run/attempt and response.
  2. Confirm exact event/fork/Dependabot state and source/workflow SHA.
  3. Read the job's explicit permission block.
  4. Confirm API method/resource/API version.
  5. Inspect status and safe response headers.
  6. Separate permission/policy from auth, rate limit, network, runtime and resource-ID failures.
  7. Apply the narrowest change.
  8. Rerun the same request and verify the exact side effect.
  9. Restore/close only the disposable resource created by the lab.
Next lesson

Checkpoint: prove deny → grant → effect

Start from zero default permissions, preserve an expected 403, grant one narrow issue mutation capability, and produce a complete authorization evidence packet.

Knowledge check

Why is write-all a poor response to a 403?

What should you inspect before retrying a 403?

Why is a PAT not a neutral diagnostic substitute for GITHUB_TOKEN?

A manual run can create an issue but a fork PR run cannot. Is that evidence of runner flakiness?

What is the causal repair when the endpoint requires issues: write and the job declares issues: read?

Official references and version notes

Version and compatibility note

Version-sensitive token/permission/API behavior was rechecked against current primary GitHub documentation on 2026-09-09. Mandatory labs use a learner-owned disposable repository, ubuntu-24.04, built-in Bash/cURL/Python 3 only, and no third-party action, PAT, GitHub App private key, cloud credential, package, deployment, environment secret, self-hosted runner, or enterprise-only feature. At verification time, GitHub creates a repository-scoped GitHub App installation access token for each job; on GitHub-hosted runners its effective lifetime is bounded by the job (maximum hosted job duration 6 hours), and it is available through both secrets.GITHUB_TOKEN and github.token. Current workflow syntax supports explicit permissions at workflow or job scope; after any permission is specified, unspecified configurable scopes become none. Current configurable keys include actions, artifact-metadata, attestations, checks, code-quality, contents, deployments, id-token, issues, discussions, packages, pages, pull-requests, security-events, statuses, and vulnerability-alerts; re-check the live syntax before future generation because GitHub is continuously delivered. Permission calculation begins from enterprise/organization/repository defaults, is adjusted by workflow and then job configuration, and can be downgraded again for fork pull-request execution. Dependabot-triggered runs are treated with fork-like restrictions. The current REST API examples use X-GitHub-Api-Version: 2026-03-10. The lab never prints authorization headers or token values; it records endpoint, method, status, response request ID/permission hints, run ID/attempt, source SHA, and the exact disposable issue created/closed. The diagnostic examples never use curl -v, set -x, whole environment/context dumps, or token-bearing transcript capture.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.