Skip to content

Dashtid/subcheck

Repository files navigation

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, 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.

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 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.

Install

pip install subcheck        # once published
# or from a clone:
pip install -e ".[dev]"

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
  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.

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.

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 — 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"

In a workflow

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.

About

Decode and validate GitHub Actions OIDC token claims against an expected-claims policy - a CI gate against trust-policy drift

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages