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.
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: readandissues: 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?
Authentication proves the job's identity; authorization still
checks the token's effective permissions. The lab intentionally
grants issues: read but not
issues: write.
Why does the secret listing not print the secret value?
GitHub exposes secret metadata but not stored secret plaintext through normal listing interfaces. Workflows receive the value only when the secret is made available to the job.
Why is printing the secret to confirm masking a bad test?
Automatic redaction is not guaranteed for transformed values and a log is itself a disclosure surface. Prove only presence or behavior, not plaintext.
What proves the denied issue write had no effect?
The HTTP 403 plus an independent issue search showing no matching issue. This separates API-response evidence from hosted-state evidence.
When would you replace a cloud secret with OIDC?
When the external provider can trust GitHub OIDC claims and issue a short-lived credential. That removes the need to keep a long-lived cloud key in GitHub.
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.
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.