Decode a GitHub Actions OIDC token's claims and check them against an expected-claims policy — so a workflow fails before an over-broad cloud trust policy lets the wrong branch, pull request, or fork assume your role.
Named for the claim that decides everything — sub. A focused sibling of
subvectors, the conformance test-vector suite (an "answer
key") that grades whether those trust conditions are well-formed, matching, and safe.
$ subcheck --claims examples/claims-pull-request.json --policy examples/policy.json
OIDC claim inspection: FAIL (5 pass, 1 fail, 1 missing)
[+] iss high claim 'iss' satisfies equals 'https://token.actions.githubusercontent.com'
[+] aud high claim 'aud' satisfies equals 'sts.amazonaws.com'
[+] repository high claim 'repository' satisfies equals 'acme/payments-api'
[+] repository_owner high claim 'repository_owner' satisfies equals 'acme'
[-] sub high claim 'sub'='repo:acme/payments-api:pull_request' does not satisfy matches /^repo:acme/payments-api:(ref:refs/heads/main|environment:production|ref:refs/tags/v[0-9].*)$/
[!] environment medium claim 'environment' is required but absent
[+] runner_environment medium claim 'runner_environment' satisfies equals 'github-hosted'
Exit code is non-zero on any finding, so the command drops straight into a CI step as a gate.
GitHub Actions can authenticate to AWS/Azure/GCP with a short-lived OIDC token instead of a
long-lived secret. The cloud side (e.g. an AWS IAM role's trust policy) decides which tokens
may assume the role by matching claims — above all sub
(repo:org/repo:ref:refs/heads/main, ...:environment:production, ...:pull_request, …).
The classic mistake is a trust condition that's too loose — a wildcard sub, a missing
condition, or ...:sub allowed for repo:org/* — so a pull request from a fork, or any branch,
can mint a token that assumes a privileged role. This tool pins down exactly which claims you
expect and flags the moment a token doesn't match.
It's the small, focused sibling of subvectors — the conformance test-vector suite that grades whether a cloud trust condition is well-formed, matches, and is safe. This one works the other end: it inspects a single token against a policy you write — nothing to configure, no cloud account, no network.
pip install subcheck # once published
# or from a clone:
pip install -e ".[dev]"Decode a token (claims only):
subcheck --token "$TOKEN" # or: --token - (read the JWT from stdin)Validate against a policy and gate on the result:
subcheck --token "$TOKEN" --policy .github/oidc-policy.yaml
echo $? # 0 = all matched, 1 = a claim didn't match, 2 = usage/parse errorInputs (choose one): --token <jwt> (- for stdin), --token-file <path>, or
--claims <decoded-claims.json>. Output: --format text (default) or json.
Prefer
--token -(stdin) or--token-fileover passing the JWT as an argument — a token on the command line leaks into the process list and shell history.
Scope (and honesty): this decodes the token payload for inspection — it does not verify the signature. Verifying the signature and issuer against GitHub's JWKS is the cloud provider's job at role-assumption time. Use this to catch misconfigured expectations early, not as an authentication control.
YAML or JSON. issuer/audience are shortcuts for the iss/aud claims; everything else lives
under claims. Each claim takes one or more of equals, in (list), matches (regex), glob,
and required (default true).
issuer: https://token.actions.githubusercontent.com
audience: sts.amazonaws.com
claims:
repository:
equals: acme/payments-api
sub:
matches: '^repo:acme/payments-api:(ref:refs/heads/main|environment:production)$'
runner_environment:
equals: github-hosted # reject self-hosted runners
environment:
equals: production
required: trueShorthand: a bare string means equals, a list means in:
claims:
repository_owner: acme
ref: [refs/heads/main, refs/heads/release]Matching notes: matches uses re.search, so it is not anchored — matches: pull_request
matches that substring anywhere in the value. Anchor with ^…$ when you mean the whole claim (the
examples do). equals/in compare against the value's real JSON type, so quote a number you expect
as a string; matches/glob always operate on the stringified value.
Claims that anchor the trust boundary (iss, aud, sub, repository, repository_owner,
and the immutable repository_id / repository_owner_id) are reported at high severity;
contextual claims default to medium.
GitHub is migrating the sub claim to an immutable format that embeds numeric owner/repo IDs —
repo:acme@123456/payments-api@456789:ref:refs/heads/main — automatic for repositories created,
renamed, or transferred after 2026-07-15. A trust condition (or a sub pattern here) written
for the legacy repo:owner/repo:... names silently stops matching, and deploys break with no code
change.
subcheck decodes both formats, reports which one a token uses, and flags the mismatch. Run a post-migration token against a name-based policy and it points straight at the cause (rows trimmed):
$ subcheck --claims examples/claims-immutable.json --policy examples/policy.json
OIDC claim inspection: FAIL (6 pass, 1 fail, 0 missing)
...
[-] sub high claim 'sub'='repo:acme@123456/payments-api@456789:ref:refs/heads/main' does not satisfy matches /^repo:acme/payments-api:(ref:refs/heads/main|environment:production|ref:refs/tags/v[0-9].*)$/
...
Notes:
[i] sub uses the immutable format (repository_owner_id=123456, repository_id=456789); pin these numeric IDs in the cloud trust policy rather than mutable owner/repo names.
[i] the sub check failed while the token is immutable-format and the expected pattern looks name-based; update the expected sub, or pin repository_id / repository_owner_id instead.
The durable fix is to pin the numeric IDs — stable across renames and transfers — as in
examples/policy-immutable.json:
claims:
repository_owner_id: "123456"
repository_id: "456789"permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- run: pip install subcheck
- name: Verify the OIDC token is scoped as expected
run: |
TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
echo "$TOKEN" | subcheck --token - --policy .github/oidc-policy.yamlpip install -e ".[dev]"
pytest -q # tests
ruff check . # lint
bandit -r src # security lintContributions welcome — see CONTRIBUTING.md; good first issues are labelled.
- subvectors — the sibling project, working the other end of the same trust boundary. subcheck checks the token a job received against your expected claims; subvectors is the cloud side — a cited, versioned suite of conformance test vectors answering "does subject S satisfy trust condition C, and is C safe?" across AWS IAM, Azure FIC, and GCP WIF. subvectors grades the trust rules; subcheck asserts the token.
MIT — see LICENSE.