subcheck
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, workflow, or trigger 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.
Why
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 token minted by something you never
intended can assume a privileged role. This tool pins down exactly which claims you expect and
flags the moment a token doesn't match.
Who can actually mint such a token. Not a fork's pull request: for
pull_requestruns from a fork GitHub downgradesid-token: writeand never injectsACTIONS_ID_TOKEN_REQUEST_TOKEN, so a fork cannot obtain a token for the upstream repo. The real paths are anyone with push/branch-create access (a wildcardsubthen covers their branch),pull_request_targetorworkflow_runjobs that check out untrusted code, and a compromised third-party action running inside an already-trusted job.
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.
Install
pip install subcheck
# or from a clone:
pip install -e ".[dev]"
Or skip installing entirely and use the GitHub Action — see In a workflow.
Usage
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 error
Inputs (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.
Policy
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 (see note below)
environment:
equals: production
required: true
Shorthand: 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.
glob is fnmatch-based: case-sensitive, * spans any characters (including / and :), ?
matches one. That lines up with AWS IAM StringLike on the properties that matter, with one
divergence — fnmatch also honours POSIX character classes, so glob: 'repo:acme/[ap]*' matches
here while IAM treats [ and ] as literals and matches nothing. Avoid character classes if the
pattern is meant to mirror a trust policy. This is an expectation language, not a cloud-semantics
simulator (see subvectors for that).
Claims that anchor the trust boundary (iss, aud, sub, repository, repository_owner,
repository_id, repository_owner_id, and job_workflow_ref) are reported at high severity;
contextual claims default to medium.
Why check claims the cloud already checks? Because for several of them it can't.
runner_environment is the clearest case: "reject self-hosted runners" is not expressible in an
AWS IAM trust policy at all — nor are event_name, head_ref, base_ref, or workflow_ref. AWS
exposes only a fixed set of GitHub claims as condition keys, and note that repository_owner is not
among them (only repository_owner_id is), so a name-based owner pin here has no trust-policy
equivalent. Asserting those claims in the job is the only place they can be enforced.
Immutable subject claims (2026-07-15)
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. Adoption is automatic for repositories
created, renamed, or transferred after 2026-07-15, but any repository can be switched on sooner
through the org-level or repo-level immutable-subject setting — so you cannot infer the format from
a repo's age. 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.
Immutable subject claims are github.com only; GitHub Enterprise Server keeps the mutable
name-based format under its own https://HOSTNAME/_services/token issuer, and subcheck suppresses
these migration hints for any non-github.com issuer.
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"
repository_id and repository_owner_id are not new — they have been separate claims in every
token since January 2023 and are present on legacy-format tokens too. You do not have to wait for
the migration to pin them; doing it now is what makes a policy survive the switch.
In a workflow
The one-line form — this repo ships an action.yml that requests the job's OIDC
token and checks it against your policy:
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v7
- name: Verify the OIDC token is scoped as expected
uses: Dashtid/subcheck@v0.3.0 # or @main
with:
policy: .github/oidc-policy.yaml
audience: sts.amazonaws.com # default
Or by hand, if you'd rather see every moving part:
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v7
- 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.yaml
Development
pip install -e ".[dev]"
pytest -q # tests
ruff check . # lint
bandit -r src # security lint
Contributions welcome — see CONTRIBUTING.md; good first issues are labelled.
Related tools
- 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.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file subcheck-0.3.0.tar.gz.
File metadata
- Download URL: subcheck-0.3.0.tar.gz
- Upload date:
- Size: 31.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
751457fbd73c4a2c841afa37600f9afd6aa7a7b8dabc151b03b746be8ad078bf
|
|
| MD5 |
9bb4e8fe2cc04060bbf0ecbe5fc4e819
|
|
| BLAKE2b-256 |
0e11266356cb16a4ef35008196b2aa5c6040dd04794a5ff8b6da9aeb04f4c17b
|
Provenance
The following attestation bundles were made for subcheck-0.3.0.tar.gz:
Publisher:
release.yml on Dashtid/subcheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
subcheck-0.3.0.tar.gz -
Subject digest:
751457fbd73c4a2c841afa37600f9afd6aa7a7b8dabc151b03b746be8ad078bf - Sigstore transparency entry: 2582945903
- Sigstore integration time:
-
Permalink:
Dashtid/subcheck@50afce06b4fe6f8bc775a634fd70676272a5bf40 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/Dashtid
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@50afce06b4fe6f8bc775a634fd70676272a5bf40 -
Trigger Event:
release
-
Statement type:
File details
Details for the file subcheck-0.3.0-py3-none-any.whl.
File metadata
- Download URL: subcheck-0.3.0-py3-none-any.whl
- Upload date:
- Size: 16.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
060cc22287e6a254855789984c99cffc2fa15c1063b60c32f7a3ac46e62321c9
|
|
| MD5 |
e6f71844db009425e8a9aa554081ed6a
|
|
| BLAKE2b-256 |
5acf8256c008db056808de9d5fe75e6d0ae572f49285c299b7f7410daaf769f3
|
Provenance
The following attestation bundles were made for subcheck-0.3.0-py3-none-any.whl:
Publisher:
release.yml on Dashtid/subcheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
subcheck-0.3.0-py3-none-any.whl -
Subject digest:
060cc22287e6a254855789984c99cffc2fa15c1063b60c32f7a3ac46e62321c9 - Sigstore transparency entry: 2582945909
- Sigstore integration time:
-
Permalink:
Dashtid/subcheck@50afce06b4fe6f8bc775a634fd70676272a5bf40 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/Dashtid
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@50afce06b4fe6f8bc775a634fd70676272a5bf40 -
Trigger Event:
release
-
Statement type: