Checkpoint Lab — Secrets, Configuration Variables, GITHUB_TOKEN, Fine-Grained Permissions, and OIDC
The checkpoint combines the chapter's trust boundaries without requiring a cloud account: one job proves narrowly scoped GitHub authorization and safe secret/variable handling; a separate job receives only OIDC request capability. You then construct and attack-test a fictional cloud trust policy using the repository's real immutable identity fields.
Learning objectives
- Create and remove temporary repository configuration without exposing secret plaintext.
-
Prove both allowed and denied GitHub API behavior under an
explicit least-privilege
GITHUB_TOKENpolicy. - Prove OIDC request capability exists without logging the request bearer token or requesting a JWT.
- Build a provider-neutral trust-policy fixture from repository owner/repository IDs, approved ref, environment, and workflow identity.
- Threat-model wrong-repository, wrong-ref, wrong-environment, secret leakage, and over-broad-token paths, then write a production operating policy.
Checkpoint assumptions: GitHub.com, a disposable public personal repository, GitHub CLI, and standard hosted runners. No paid GitHub plan or cloud account is required. The OIDC job intentionally stops before requesting a JWT or provider credential. If you later perform a real cloud exchange, use the provider's official GitHub OIDC guide and a disposable sandbox account.
Shell note: Local command blocks in this lab are
labeled for Bash/Git Bash. On Windows, Git Bash is a direct
compatible path. If you use PowerShell instead, keep the same GitHub
resources and security rules but translate shell-only quoting,
here-documents, test, and jq pipelines
rather than copying them verbatim.
1. Preflight and predictions
Create a fresh repository named
github-identity-checkpoint or reuse a still-disposable
Chapter 20 lab repository. Before any mutation, record repository
identity and current secret/variable metadata.
LAB="github-identity-checkpoint"
gh repo create "$LAB" --public --add-readme --clone
cd "$LAB"
REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)"
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO" --jq '{id,owner_id:.owner.id,full_name,visibility,default_branch}'
gh secret list -R "$REPO" --json name,updatedAt
gh variable list -R "$REPO" --json name,value,updatedAt
Prediction A: adding a secret and variable changes
hosted Actions configuration but creates no Git commit.
Prediction B: the first job will authenticate
successfully for GET but cannot create an issue.
Prediction C: the OIDC job will expose the two
request-channel environment variables because it has
id-token: write, but no provider credential will exist
because the workflow never performs an exchange.
2. Add temporary configuration safely
gh variable set CH20_CHECKPOINT_MODE --body "checkpoint" -R "$REPO"
CHECKPOINT_SECRET="ch20-checkpoint-$(openssl rand -hex 12)"
printf '%s' "$CHECKPOINT_SECRET" | gh secret set CH20_CHECKPOINT_SECRET -R "$REPO"
unset CHECKPOINT_SECRET
gh secret list -R "$REPO" --json name,updatedAt
gh variable list -R "$REPO" --json name,value,updatedAt
Notice that only the variable plaintext is inspectable. Do not add a “show me the secret” command; that would defeat the security model you are testing.
3. Install the checkpoint workflow
The two jobs deliberately have different permission contracts. The GitHub-boundary job has no OIDC authority; the OIDC-capability job has no issue-write authority.
name: Chapter 20 checkpoint
on:
workflow_dispatch:
permissions: {}
jobs:
github-boundary:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LAB_SECRET: ${{ secrets.CH20_CHECKPOINT_SECRET }}
LAB_MODE: ${{ vars.CH20_CHECKPOINT_MODE }}
steps:
- name: Validate non-secret and secret presence
shell: bash
run: |
set -euo pipefail
test "$LAB_MODE" = "checkpoint"
test -n "$LAB_SECRET"
echo "mode=$LAB_MODE"
echo "secret_present=true"
- name: Allowed API read
shell: bash
run: |
set -euo pipefail
gh api \
-H "X-GitHub-Api-Version: 2026-03-10" \
"repos/$GITHUB_REPOSITORY" \
--jq '{id,owner_id:.owner.id,full_name,default_branch}'
- name: Denied API write must remain denied
shell: bash
run: |
set -euo pipefail
response="$(mktemp)"
status="$(
curl --silent --show-error \
--output "$response" \
--write-out '%{http_code}' \
--request POST \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer $GH_TOKEN" \
--header "X-GitHub-Api-Version: 2026-03-10" \
"https://api.github.com/repos/$GITHUB_REPOSITORY/issues" \
--data '{"title":"chapter-20-denied-write"}'
)"
echo "write_status=$status"
jq -r '"denial_message=" + (.message // "no-message")' "$response"
test "$status" = "403"
oidc-capability:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Prove OIDC request capability without disclosing it
shell: bash
run: |
set -euo pipefail
test -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}"
test -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}"
echo "oidc_request_url_present=true"
echo "oidc_request_bearer_present=true"
echo "No OIDC JWT was requested in this lab."
Create .github/workflows/ch20-checkpoint.yml with
exactly that workflow, commit, and push it to the default branch. No
checkout or third-party action is required, so there is no extra
dependency or persisted Git credential in this checkpoint.
mkdir -p .github/workflows
# Save the workflow above as .github/workflows/ch20-checkpoint.yml
git add .github/workflows/ch20-checkpoint.yml
git commit -m "Add Chapter 20 security checkpoint"
git push
4. Run and preserve identity evidence
gh workflow run ch20-checkpoint.yml -R "$REPO" --ref "$DEFAULT_BRANCH"
RUN_ID="$(
gh run list -R "$REPO" --workflow ch20-checkpoint.yml --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId'
)"
gh run watch "$RUN_ID" -R "$REPO" --exit-status
gh run view "$RUN_ID" -R "$REPO" --json databaseId,event,headBranch,headSha,status,conclusion,url
gh run view "$RUN_ID" -R "$REPO" --log | grep -E 'mode=|secret_present=|write_status=|denial_message=|oidc_request_'
Expected lines include mode=checkpoint,
secret_present=true, write_status=403, and
both OIDC-request capability flags set to true. There must be no
JWT, request URL, request bearer token, PAT, or secret plaintext in
the log.
5. Verify the denied mutation independently
gh issue list -R "$REPO" --state all --search '"chapter-20-denied-write" in:title' --json number,title,state
The expected result is an empty array. If an issue exists, stop: the token had more write authority than the workflow intended, or the test was run under a different credential/context. Preserve the run before changing anything.
6. Build the current immutable OIDC identity input
Retrieve numeric identity fields from GitHub instead of hard-coding a repository name as the only trust anchor.
REPO_JSON="$(
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO"
)"
REPO_ID="$(jq -r '.id' <<<"$REPO_JSON")"
OWNER_ID="$(jq -r '.owner.id' <<<"$REPO_JSON")"
OWNER="$(jq -r '.owner.login' <<<"$REPO_JSON")"
NAME="$(jq -r '.name' <<<"$REPO_JSON")"
printf 'owner=%s owner_id=%s repo=%s repo_id=%s\n' "$OWNER" "$OWNER_ID" "$NAME" "$REPO_ID"
EXPECTED_ENV_SUB="repo:${OWNER}@${OWNER_ID}/${NAME}@${REPO_ID}:environment:production"
printf 'expected_environment_subject=%s\n' "$EXPECTED_ENV_SUB"
For a GitHub.com repository created now, this matches the current immutable default subject family. If you use an older repository, a renamed/transferred repository, GHES, or customized OIDC subject template, inspect the actual official format instead of assuming this exact string.
7. Create and attack-test a provider-neutral trust policy
The fixture expresses security intent. It is not a cloud-provider policy language and does not replace JWT signature verification.
cat > trust-policy.json <<JSON
{
"issuer": "https://token.actions.githubusercontent.com",
"audience": "https://cloud.example.invalid",
"repository_owner_id": "$OWNER_ID",
"repository_id": "$REPO_ID",
"ref": "refs/heads/$DEFAULT_BRANCH",
"environment": "production",
"workflow_ref_suffix": "/.github/workflows/ch20-checkpoint.yml@refs/heads/$DEFAULT_BRANCH"
}
JSON
cat > evaluate-policy.py <<'PYCODE'
import json, sys
policy=json.load(open("trust-policy.json", encoding="utf-8"))
claims=json.load(sys.stdin)
checks = {
"issuer": claims.get("iss") == policy["issuer"],
"audience": claims.get("aud") == policy["audience"],
"owner_id": str(claims.get("repository_owner_id")) == str(policy["repository_owner_id"]),
"repo_id": str(claims.get("repository_id")) == str(policy["repository_id"]),
"ref": claims.get("ref") == policy["ref"],
"environment": claims.get("environment") == policy["environment"],
"workflow": str(claims.get("workflow_ref","")).endswith(policy["workflow_ref_suffix"]),
}
failed=[k for k,v in checks.items() if not v]
print(json.dumps({"checks":checks,"allowed":not failed,"failed":failed}, indent=2))
sys.exit(0 if not failed else 3)
PYCODE
Create four synthetic claim files: approved, wrong repository ID, feature branch, and wrong environment. The evaluator should exit 0 only for the approved fixture and 3 for each attack case.
# Example approved claim fixture. Replace IDs from your trust-policy.json.
jq -n \
--arg oid "$OWNER_ID" \
--arg rid "$REPO_ID" \
--arg ref "refs/heads/$DEFAULT_BRANCH" \
--arg wf "$OWNER/$NAME/.github/workflows/ch20-checkpoint.yml@refs/heads/$DEFAULT_BRANCH" \
'{
iss:"https://token.actions.githubusercontent.com",
aud:"https://cloud.example.invalid",
repository_owner_id:$oid,
repository_id:$rid,
ref:$ref,
environment:"production",
workflow_ref:$wf
}' > approved.json
python evaluate-policy.py < approved.json
For the attack fixtures, change exactly one of
repository_id, ref, or
environment. Preserve each result in your checkpoint
notes. A production provider must also verify the signed JWT
cryptographically and enforce its own short-lived role permissions.
8. Production identity runbook
| Control | Production rule | Evidence |
|---|---|---|
| Secrets | No long-lived credential unless federation/App/token design cannot meet the need; scope to environment/repository as narrowly as practical. | Secret metadata, owner, rotation date; never plaintext. |
| Variables | Non-sensitive only; decide whether hosted mutability or repository review is more appropriate. | Variable metadata/value and change process. |
| GITHUB_TOKEN | Deny by default; grant job-specific permissions. | Workflow revision plus controlled allowed/denied API tests. |
| Cross-repo GitHub automation | Prefer GitHub App for durable integration; FG PAT only when user-context/compatibility requires it. | App installation/permission policy or PAT owner/resources/expiry. |
| OIDC | Exact issuer/audience/immutable repository identity; bind environment/ref/workflow where provider supports it. | Provider trust-policy revision + run claims + provider audit log. |
| Credential leak | Revoke/rotate first, then contain evidence and fix code. | Revocation time, replacement scope, incident record. |
9. Cleanup and verification
gh secret delete CH20_CHECKPOINT_SECRET -R "$REPO"
gh variable delete CH20_CHECKPOINT_MODE -R "$REPO"
gh secret list -R "$REPO" --json name
gh variable list -R "$REPO" --json name,value
gh repo archive "$REPO" --yes
- The temporary secret and variable are gone.
- No denied-write issue exists.
- No live OIDC JWT/cloud credential was created by the mandatory lab.
- Logs contain presence/status evidence only, not token/secret values.
- The repository remains archived rather than permanently deleted, preserving the checkpoint evidence.
Knowledge checks
Why are the GitHub-boundary and OIDC-capability checks in separate jobs?
Permissions are job-scoped. Separation proves that the job needing OIDC request capability does not automatically receive unrelated GitHub write authority and vice versa.
The issue POST returned 403 but the job passed. Why?
The workflow treats denial as the expected security assertion and explicitly tests for HTTP 403. A denied unauthorized mutation is a passing control.
Why does the checkpoint not request a real OIDC JWT?
The learning goal is permissions, claims, and trust-policy design. Avoiding a real token prevents credential exposure and removes any cloud-account requirement; a real exchange is optional provider-specific work.
A new repository's trust policy matches name-only
repo:OWNER/REPO:.... What should you verify
first?
Whether the repository uses GitHub.com's post–July 15, 2026 immutable default subject containing owner/repository IDs. New repositories should not assume the historical format.
A policy matches repository ID and environment but ignores ref, while only main is approved. What attack path remains?
Another branch in the same repository could potentially satisfy the trust boundary. Bind the ref through provider claims where supported and/or enforce the branch before the environment job.
What is the first response if a real PAT or cloud credential appears in an Actions log?
Revoke or rotate it immediately. Log/history cleanup is secondary because the exposed credential must already be treated as compromised.
Chapter 20 production model
You now have a practical identity model for GitHub Actions: classify
data before storing it, scope secrets to the smallest trust
boundary, treat GITHUB_TOKEN as a short-lived
repository identity with explicit job permissions, use Apps or
fine-grained PATs only for the resource/identity gap they actually
solve, and prefer OIDC for cloud access when a provider can issue
short-lived credentials from narrowly matched claims.
Bridge to Chapter 21: Chapter 21 applies these identity controls to GitHub Packages and the Container Registry, where package ownership, repository inheritance, workflow tokens, provenance, and distribution permissions become the next supply-chain boundary.
Further reading
- GitHub Docs — GITHUB_TOKEN
- GitHub Docs — Workflow syntax: permissions
- GitHub Docs — Secrets
- GitHub Docs — Variables
- GitHub Docs — OpenID Connect reference
- GitHub Docs — Secure use reference
- GitHub Docs — Personal access tokens
- GitHub Docs — Deciding when to build a GitHub App
- GitHub REST API — API versions
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.