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.
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: writeonly 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.
Knowledge check
Why does the denied-write job intentionally assert HTTP 403 and still pass?
Because the experiment is testing that insufficient issue permission is enforced; receiving the predicted denial is success for the security test.
Why not give the mutation job
contents: write too?
Creating and closing the lab issue requires issue write authority, not content write authority. Extra scope would violate least privilege.
What proves the mutation succeeded more strongly than “curl exited zero”?
The 201 response plus exact issue number/URL, a follow-up GET showing that resource open, and cleanup showing the same issue closed.
If the same narrow write works on manual dispatch but fails on a fork PR, what should you inspect first?
The event/fork trust context and effective token downgrade, not network retries or unrelated runner state.
Why are API request/response files safe only if bounded?
They may contain repository data or sensitive headers/body fields. Preserve only needed evidence and never store Authorization headers or token values.
Official references and version notes
- GITHUB_TOKEN concept — current token issuance, repository scope, lifetime, and workflow-trigger behavior.
- Use GITHUB_TOKEN for authentication in workflows — current examples for authenticating API/action requests and minimizing token permissions.
-
Workflow syntax — permissions
— current permission keys, workflow/job scope, calculation order,
fork adjustment, and
permissions: {}behavior. - Managing GitHub Actions settings for a repository — current repository default workflow-permission settings and organization inheritance.
- Authenticating to the REST API — current bearer authentication and versioned REST request guidance.
-
REST API — issues
— current requirements for reading/creating/updating issues; issue
creation requires Issues repository permission
writefor fine-grained/App-style tokens. -
Troubleshooting the REST API
— current 401/403/404 diagnostics and
X-Accepted-GitHub-Permissionsguidance. -
Triggering a workflow
— current recursion prevention when events are created with
GITHUB_TOKENand documented exceptions. - Events that trigger workflows — current fork pull-request token/secrets restrictions and event trust boundaries.
- Dependabot on GitHub Actions — current read-only token/secrets restrictions for Dependabot-triggered runs.
-
Secure use reference
— least-privilege guidance, untrusted-input boundaries, and the
fact that actions can access
github.token. -
About authentication to GitHub
— current recommendation to prefer built-in
GITHUB_TOKENin Actions and GitHub Apps for broader/cross-repository automation.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.