Chapter 20Lesson 05~225 minutes

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.

CheckpointLeast privilegeDenied writeOIDC claimsTrust policy

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_TOKEN policy.
  • 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?

The issue POST returned 403 but the job passed. Why?

Why does the checkpoint not request a real OIDC JWT?

A new repository's trust policy matches name-only repo:OWNER/REPO:.... What should you verify first?

A policy matches repository ID and environment but ignores ref, while only main is approved. What attack path remains?

What is the first response if a real PAT or cloud credential appears in an Actions log?

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.

Next chapter

GitHub Packages, Container Registry, Package Permissions, Provenance, and Distribution: Concepts, Architecture, and Mental Model

Further reading

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.