A cloud access key stored as a secret works everywhere and forever, for an attacker too. With OpenID Connect (OIDC), a job asks GitHub 29 for a signed JSON Web Token (JWT) describing the job, presents it to the cloud provider, and receives short-lived credentials. The cloud trusts GitHub's issuer, https://token.actions.githubusercontent.com 743 , and only tokens whose claims match its conditions. BookNest has no cloud account (AWS CloudFormation uses LocalStack 63,725 ), but oidc.yml requests real tokens. Its permissions include id-token: write, which makes the runner provide the request URL and token, and each of its two jobs, branch and staging (with environment: staging), runs:
#!/usr/bin/env bash
# Requests the job's OIDC token for audience $1; prints four decoded claims, never the token.
set -euo pipefail
token=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$1" | jq -r .value)
echo "::add-mask::$token"
payload=$(cut -d. -f2 <<< "$token" | tr '_-' '/+')
while (( ${#payload} % 4 )); do payload+="="; done
base64 -d <<< "$payload" | jq -c '{sub, aud, repository, ref}'A JWT is three Base64URL parts: header, payload, signature. The script masks the token and decodes the payload:
gh run view 36136374166 --log | cut -f1,3 | sed 's/\t[^ ]* /: /' | grep '"sub"'staging: {"sub":"repo:binarybehemoth@15277380/booknest@1387250434:environment:staging","aud":"s
ts.amazonaws.com"
,"repository":"binarybehemoth/booknest","ref":"refs/heads/main"}
branch: {"sub":"repo:binarybehemoth@15277380/booknest@1387250434:ref:refs/heads/main","aud":"st
s.amazonaws.com",
"repository":"binarybehemoth/booknest","ref":"refs/heads/main"}sub is what trust policies match: ...:environment:staging for a job with an environment, the ref otherwise, ...:pull_request for pull requests. The @ IDs are new: repositories created since 15 July 2026 (BookNest was created on 25 September) get immutable subjects with the owner's and repository's numeric IDs, so a recreated or renamed repository of the same name cannot match an old trust policy. Older repositories keep repo:owner/name:... until they opt in, the form most published examples still show.