Chapter 06Lesson 02~175 minutes

GITHUB_TOKEN, Workflow Permissions, Least Privilege, and API Access: Guided Hands-On Workflow

This lesson makes least privilege observable rather than theoretical. One workflow begins with permissions: {}, gives a read job only contents: read, gives a second job only issues: read and proves that issue creation is denied, then gives a third job only issues: write and creates exactly one synthetic issue in the disposable repository. The denied result is preserved; the successful mutation is verified and closed by exact issue number.

Hands-onREST API403 evidenceissues: writeExact cleanup

Learning objectives

  • Execute a harmless authenticated REST read using only the permission that endpoint needs.
  • Preserve and interpret an expected under-privileged issue-create response without exposing the token.
  • Grant issues: write only to the mutation job and verify the exact created resource.
  • Capture API version, request status, request ID, run identity, actor, source SHA, and repository effect as evidence.
  • Close only the issue created by the lab and explain why blindly retrying a 403 is incorrect.

1. Disposable repository and preflight

Create a learner-owned repository such as gha-token-lab with Issues enabled and a committed README.md. Use a manual trigger. The lab creates one issue and then closes it; do not point this workflow at a shared or production repository.

Requirement Expected
Workflow path .github/workflows/ch06-token-lab.yml
Trigger workflow_dispatch
Top-level permissions {}
Runner ubuntu-24.04
Tools curl, Bash, Python 3; record versions before use.
External credentials None. Use only github.token.
Mutation ceiling One issue whose title contains current run ID + attempt; close only that issue.
curl --version | head -n 1
python3 --version
printf 'run=%s attempt=%s actor=%s repo=%s sha=%s\n' \
  "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_ACTOR" "$GITHUB_REPOSITORY" "$GITHUB_SHA"

2. Progressive workflow: zero default, read, denied write, narrow mutation

name: Chapter 06 least-privilege API lab
on:
  workflow_dispatch:

permissions: {}

env:
  API_VERSION: '2026-03-10'

jobs:
  read_source:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    steps:
      - name: Read README at exact source SHA
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          url="https://api.github.com/repos/$GITHUB_REPOSITORY/contents/README.md?ref=$GITHUB_SHA"
          status=$(curl -sS -D read.headers -o read.json -w '%{http_code}' \
            -H 'Accept: application/vnd.github+json' \
            -H "Authorization: Bearer $GH_TOKEN" \
            -H "X-GitHub-Api-Version: $API_VERSION" \
            "$url")
          printf 'read_status=%s\n' "$status"
          grep -i '^x-github-request-id:' read.headers || true
          test "$status" = '200'

  denied_write:
    needs: read_source
    runs-on: ubuntu-24.04
    permissions:
      issues: read
    steps:
      - name: Prove issue creation is denied
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          title="ch06-denied-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          python3 - "$title" > denied-request.json <<'PY'
          import json, sys
          print(json.dumps({"title": sys.argv[1], "body": "Synthetic denied-write probe."}))
          PY
          status=$(curl -sS -D denied.headers -o denied.json -w '%{http_code}' \
            -X POST \
            -H 'Accept: application/vnd.github+json' \
            -H "Authorization: Bearer $GH_TOKEN" \
            -H "X-GitHub-Api-Version: $API_VERSION" \
            "https://api.github.com/repos/$GITHUB_REPOSITORY/issues" \
            --data-binary @denied-request.json)
          printf 'denied_status=%s\n' "$status"
          grep -i -E '^(x-github-request-id|x-accepted-github-permissions|x-ratelimit-remaining):' denied.headers || true
          test "$status" = '403'

  mutate_issue:
    needs: denied_write
    runs-on: ubuntu-24.04
    permissions:
      issues: write
    steps:
      - name: Create exactly one synthetic issue
        id: create
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          title="ch06-write-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          python3 - "$title" "$GITHUB_SHA" > create-request.json <<'PY'
          import json, sys
          print(json.dumps({
            "title": sys.argv[1],
            "body": f"Disposable Chapter 06 lab issue. Source SHA: {sys.argv[2]}"
          }))
          PY
          status=$(curl -sS -D create.headers -o create.json -w '%{http_code}' \
            -X POST \
            -H 'Accept: application/vnd.github+json' \
            -H "Authorization: Bearer $GH_TOKEN" \
            -H "X-GitHub-Api-Version: $API_VERSION" \
            "https://api.github.com/repos/$GITHUB_REPOSITORY/issues" \
            --data-binary @create-request.json)
          test "$status" = '201'
          python3 - <<'PY' >> "$GITHUB_OUTPUT"
          import json
          data=json.load(open('create.json', encoding='utf-8'))
          print(f"issue_number={data['number']}")
          print(f"issue_url={data['html_url']}")
          print(f"issue_state={data['state']}")
          PY

      - name: Verify the exact issue
        env:
          GH_TOKEN: ${{ github.token }}
          ISSUE_NUMBER: ${{ steps.create.outputs.issue_number }}
        run: |
          set -euo pipefail
          status=$(curl -sS -o verify.json -w '%{http_code}' \
            -H 'Accept: application/vnd.github+json' \
            -H "Authorization: Bearer $GH_TOKEN" \
            -H "X-GitHub-Api-Version: $API_VERSION" \
            "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER")
          test "$status" = '200'
          python3 - "$ISSUE_NUMBER" <<'PY'
          import json, sys
          d=json.load(open('verify.json', encoding='utf-8'))
          assert str(d['number']) == sys.argv[1]
          assert d['state'] == 'open'
          print('verified_issue_number=', d['number'])
          print('verified_author=', d['user']['login'])
          PY

      - name: Cleanup - close only the created issue
        env:
          GH_TOKEN: ${{ github.token }}
          ISSUE_NUMBER: ${{ steps.create.outputs.issue_number }}
        run: |
          set -euo pipefail
          printf '%s\n' '{"state":"closed"}' > close-request.json
          status=$(curl -sS -o close.json -w '%{http_code}' \
            -X PATCH \
            -H 'Accept: application/vnd.github+json' \
            -H "Authorization: Bearer $GH_TOKEN" \
            -H "X-GitHub-Api-Version: $API_VERSION" \
            "https://api.github.com/repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER" \
            --data-binary @close-request.json)
          test "$status" = '200'
          python3 - <<'PY'
          import json
          d=json.load(open('close.json', encoding='utf-8'))
          assert d['state'] == 'closed'
          print('closed_issue_number=', d['number'])
          PY

3. Why the first job gets contents: read

The GET request addresses README.md at the exact GITHUB_SHA. It demonstrates authenticated source access without checkout and without write capability. A 200 proves this request was allowed. It does not prove the token can create issues, write contents, publish packages, or deploy.

4. Why the denied job is expected to stay green

The denied request should receive 403 because creating an issue requires issue write authority while that job requests only issues: read. The shell then asserts that 403 is the expected experimental result. The job is green because the security control behaved as predicted—not because the POST succeeded.

Preserve denied.headers and bounded denied.json text locally in the run log/evidence packet. The REST troubleshooting reference documents X-Accepted-GitHub-Permissions as a useful hint when returned.

5. Why the write job receives only issues: write

The mutation is an issue operation, so the job does not need contents: write, actions: write, pull-requests: write, or broad write-all. The permission declaration therefore becomes a reviewable statement of intent.

The title includes run ID and attempt, making the created resource unambiguous. The API response provides the issue number and URL, and the verification request proves the exact resource exists before cleanup.

6. HTTP status is evidence, but interpret the class correctly

Status Likely question Next diagnostic step
200 / 201 Was the request accepted? Verify the exact resource state; do not stop at a green step.
401 Was authentication invalid? Check token presence/format without printing it; stop repeated invalid attempts.
403 Is permission/policy/rate-limit blocking the request? Inspect permission/rate-limit/request-ID headers and event trust context before changing YAML.
404 Is the resource absent or intentionally hidden by insufficient access? Verify endpoint/resource identity and authorization; do not assume “not found” is purely routing.
410 Is the feature disabled? For issue creation, verify Issues are enabled in the disposable repository.

7. Small challenge: choose the capability layer

Suppose the read job succeeds, the denied write gives 403, and the narrow write also gives 403 only when the workflow is triggered from a forked pull request. Which layer changed?

The workflow source can be identical. The changed layer is event trust / effective token permission after fork adjustment. Do not “fix” that by switching to pull_request_target and executing untrusted fork code with a privileged token.

8. Evidence packet for the guided workflow

  • Workflow SHA, event, actor, repository, run ID, attempt.
  • Top-level permissions: {} and each job's explicit permission block.
  • Read request method/resource/API version/status.
  • Denied create request status + request ID + accepted-permission/rate-limit headers if present.
  • Created issue number, URL, initial state, author identity.
  • Verification response showing the exact issue is open.
  • Cleanup response showing the same issue number is closed.
  • Statement that no PAT/App/cloud secret or external action was used.

9. Result: permission failures are now testable

You have proven three separate claims: one bounded read is allowed; an under-privileged write is denied; and a job with exactly the required write scope can create and close one disposable issue. That is stronger than a generic “token works” statement.

Next lesson

Permission architecture and identity choices

Choose between defaults and explicit declarations, workflow- and job-level scopes, GITHUB_TOKEN and broader identities, and read-only versus mutation jobs.

Knowledge check

Why does the denied-write job intentionally assert HTTP 403 and still pass?

Why not give the mutation job contents: write too?

What proves the mutation succeeded more strongly than “curl exited zero”?

If the same narrow write works on manual dispatch but fails on a fork PR, what should you inspect first?

Why are API request/response files safe only if bounded?

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 lab intentionally mutates only one issue in a disposable repository and immediately closes that exact issue; GitHub issues do not support ordinary deletion, so closure is the reversible cleanup action.

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.