Chapter 20Lesson 02~220 minutes

Secrets, Configuration Variables, GITHUB_TOKEN, Fine-Grained Permissions, and OIDC: Guided Hands-On Workflow and Core Operations

Now make the security model observable. This lesson creates a disposable repository, adds a harmless synthetic secret and a normal configuration variable, then proves that the same authenticated workflow can read repository metadata while a write operation is denied by explicit permissions.

Disposable labgh secretgh variable403 evidenceOIDC simulation

Learning objectives

  • Create and inspect a temporary repository secret without printing its plaintext and a non-sensitive repository variable whose value is intentionally visible.
  • Run a workflow with explicit contents: read and issues: read, then distinguish an authenticated read from an authorization-denied write.
  • Preserve run ID, head SHA, API status, and selected logs as evidence without leaking credentials or whole contexts.
  • Explain why masking is a last-line defense rather than permission control.
  • Model OIDC claims and trust evaluation locally without requesting a real provider credential.

Lab assumptions: GitHub.com, GitHub CLI authenticated to your own account, a disposable public repository, and standard GitHub-hosted runners. You need permission to create the disposable repository and configure its Actions secrets/variables. No cloud account, organization, PAT, GitHub App, or paid feature is required.

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. Setup and preflight

Use a new repository so the deliberately denied write and temporary credential configuration cannot affect valuable work. The secret is random training data, not a real API key.

LAB="github-identity-lab"
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)"
printf 'repo=%s\nbranch=%s\n' "$REPO" "$DEFAULT_BRANCH"

gh auth status
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO"   --jq '{full_name,visibility,default_branch}'

Expected state: a public repository you own, no Chapter 20 secrets/variables yet, and an authenticated CLI identity. Stop if REPO is not the disposable repository.

2. Create one secret and one variable through different channels

The variable is safe to expose and therefore belongs in a normal configuration variable. The synthetic secret is streamed from local memory to gh secret set; it is never pasted into a workflow file or shell history as a literal credential.

gh variable set CH20_MODE --body "training" -R "$REPO"

DEMO_SECRET="ch20-training-$(openssl rand -hex 12)"
printf '%s' "$DEMO_SECRET" | gh secret set CH20_DEMO_SECRET -R "$REPO"
unset DEMO_SECRET

gh variable list -R "$REPO" --json name,value,updatedAt
gh secret list -R "$REPO" --json name,updatedAt

You should see CH20_MODE and its value, while the secret listing contains only metadata such as its name and update time. That difference is intentional.

3. Create the least-privilege boundary workflow

The top-level permissions: {} makes the policy explicit. The one job opts into only repository-content reads and issue reads. The workflow still receives a valid GITHUB_TOKEN; the denied POST will demonstrate that identity is valid but write authorization is absent.

name: Chapter 20 identity boundary
on:
  workflow_dispatch:

permissions: {}

jobs:
  boundary:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: read
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      DEMO_SECRET: ${{ secrets.CH20_DEMO_SECRET }}
      MODE: ${{ vars.CH20_MODE }}
    steps:
      - name: Prove configuration without disclosing secret
        shell: bash
        run: |
          set -euo pipefail
          test "$MODE" = "training"
          test -n "$DEMO_SECRET"
          echo "mode=$MODE"
          echo "secret_present=true"

      - name: Allowed repository metadata read
        shell: bash
        run: |
          set -euo pipefail
          gh api \
            -H "X-GitHub-Api-Version: 2026-03-10" \
            "repos/$GITHUB_REPOSITORY" \
            --jq '{full_name,visibility,default_branch}'

      - name: Deliberately denied issue write
        shell: bash
        run: |
          set -euo pipefail
          body="$(mktemp)"
          status="$(
            curl --silent --show-error \
              --output "$body" \
              --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":"should-not-be-created"}'
          )"
          echo "write_status=$status"
          jq -r '"denial_message=" + (.message // "no-message")' "$body"
          test "$status" = "403"

Save that workflow as .github/workflows/ch20-boundary.yml, commit it, and push it to the repository default branch:

mkdir -p .github/workflows
# Save the complete workflow above as .github/workflows/ch20-boundary.yml
git add .github/workflows/ch20-boundary.yml
git commit -m "Add Chapter 20 identity boundary lab"
git push

Keep the workflow on the default branch because workflow_dispatch requires the workflow file to exist there for manual dispatch.

4. Dispatch and bind the evidence to one run

gh workflow run ch20-boundary.yml -R "$REPO" --ref "$DEFAULT_BRANCH"

RUN_ID="$(
  gh run list -R "$REPO"     --workflow ch20-boundary.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='

Expected evidence: the read step shows repository metadata; the secret check reports only secret_present=true; and the write probe records HTTP 403 plus GitHub's denial message. No issue should be created.

gh issue list -R "$REPO" --state all   --search '"should-not-be-created" in:title'   --json number,title,state

The empty result independently confirms the denied operation had no mutation effect.

5. Masking is not permission control

GitHub attempts to redact configured secret values from logs, but secret transformation can break exact-string matching. Do not test masking by printing a real or synthetic secret. If you need to demonstrate the UI mechanism, mask a known non-secret sample:

echo "::add-mask::sample-public-value"
echo "sample-public-value"

The correct production defense is to avoid logging credential material, grant minimum authority, and use short-lived credentials. Redaction only reduces damage after a logging mistake; it does not make logging secrets safe.

6. Provider-neutral OIDC simulation

This lesson does not request a live JWT. Instead, create a synthetic claim document and evaluate a fictional policy. That is enough to learn the trust decision without cloud cost or credential exposure.

cat > oidc-claims.json <<'JSON'
{
  "iss": "https://token.actions.githubusercontent.com",
  "aud": "https://cloud.example.invalid",
  "repository_id": "456789",
  "repository_owner_id": "123456",
  "ref": "refs/heads/main",
  "environment": "production",
  "workflow_ref": "octo-org/octo-repo/.github/workflows/deploy.yml@refs/heads/main"
}
JSON

jq -e '
  .iss == "https://token.actions.githubusercontent.com" and
  .aud == "https://cloud.example.invalid" and
  .repository_id == "456789" and
  .repository_owner_id == "123456" and
  .ref == "refs/heads/main" and
  .environment == "production"
' oidc-claims.json

A real provider additionally verifies the JWT signature and issuer keys, token lifetime, and its provider-specific trust-policy syntax. Never treat locally parsed JSON as equivalent to cryptographic verification.

7. Challenge: choose the correct surface

For each requirement, choose the smallest appropriate mechanism before looking at the answer: (a) a public test mode that operators may change without a commit; (b) an API credential for a test-only external service; (c) same-repository issue metadata read; (d) durable cross-repository organization bot; (e) temporary cloud deployment access.

Your target model should be: configuration variable; Actions secret; GITHUB_TOKEN; GitHub App; OIDC federation. Explain the authority and lifecycle, not only the syntax.

8. Cleanup

gh secret delete CH20_DEMO_SECRET -R "$REPO"
gh variable delete CH20_MODE -R "$REPO"

gh secret list -R "$REPO" --json name
gh variable list -R "$REPO" --json name,value

gh repo archive "$REPO" --yes

Archiving is reversible and preserves the workflow/run evidence. If you keep the repository for later lessons, still remove the temporary secret immediately.

Knowledge checks

Why can an API request return 403 even though GITHUB_TOKEN authenticated successfully?

Why does the secret listing not print the secret value?

Why is printing the secret to confirm masking a bad test?

What proves the denied issue write had no effect?

When would you replace a cloud secret with OIDC?

Lesson summary

You created two data classes deliberately, proved token authority through allowed and denied API operations, preserved run identity, and modeled OIDC without obtaining a provider credential. The next lesson turns those mechanics into design rules for production automation.

Next lesson

Secrets, Configuration Variables, GITHUB_TOKEN, Fine-Grained Permissions, and OIDC: Configuration, Design Choices, and Tradeoffs

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.