Skip to main content

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_request runs from a fork GitHub downgrades id-token: write and never injects ACTIONS_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 wildcard sub then covers their branch), pull_request_target or workflow_run jobs 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        # v0.2.0 — first PyPI release rolling out
# 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-file over 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 divergencefnmatch 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@v4
  - name: Verify the OIDC token is scoped as expected
    uses: Dashtid/subcheck@v0.2.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@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.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

subcheck-0.2.0.tar.gz (25.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

subcheck-0.2.0-py3-none-any.whl (14.8 kB view details)

Uploaded Python 3

File details

Details for the file subcheck-0.2.0.tar.gz.

File metadata

  • Download URL: subcheck-0.2.0.tar.gz
  • Upload date:
  • Size: 25.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for subcheck-0.2.0.tar.gz
Algorithm Hash digest
SHA256 1e82ec8e4347be5edca3bec0beb99f2a4847512e04b86562565ac72d40a2b867
MD5 190f915062ea51c4650a4109534003b3
BLAKE2b-256 bca3afadbd6b13ac2202ca40fa20b816cf90b235a607b456ac00c4544c242628

See more details on using hashes here.

Provenance

The following attestation bundles were made for subcheck-0.2.0.tar.gz:

Publisher: release.yml on Dashtid/subcheck

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file subcheck-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: subcheck-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 14.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for subcheck-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f1665667c9898b92704aaf37c9f1ea964cef78ed7f561fad87c571243a593b1f
MD5 3280b41281909b712c4e7de0ace200c2
BLAKE2b-256 4f57696c5e9f504c67a4cbf87d19f72d4674d7b7a668b61ab20a48a7cb7c030c

See more details on using hashes here.

Provenance

The following attestation bundles were made for subcheck-0.2.0-py3-none-any.whl:

Publisher: release.yml on Dashtid/subcheck

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.4.1

2 files

0.4.0

2 files

0.3.0

2 files

This release

0.2.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page