> ## Documentation Index
> Fetch the complete documentation index at: https://docs.grantex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Offline Verification

> Verify signatures and claims locally with a remote JWKS request per standalone call, without online token introspection.

## Overview

The `verify_grant_token()` function verifies a Grantex grant token's signature
and claims locally after retrieving the published JSON Web Key Set (JWKS). It
avoids the online verification/revocation endpoint, but the current standalone
helper fetches the remote JWKS on every invocation and is not network-free.

This is ideal for service-side verification where latency matters and you want to avoid an API round-trip for every request.

<Note>
  Hosted tokens use the canonical issuer `https://grantex.dev`, even though the
  stable JWKS URL is served from `https://api.grantex.dev`. The SDK handles this
  alias automatically. For self-hosted deployments, it derives the expected
  issuer from the JWKS URL unless you set `issuer` or `issuer_did` explicitly.
</Note>

## Usage

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions

grant = verify_grant_token(
    "eyJhbGciOiJSUzI1NiIs...",
    VerifyGrantTokenOptions(
        jwks_uri="https://api.grantex.dev/.well-known/jwks.json",
    ),
)

print(f"Principal: {grant.principal_id}")
print(f"Agent DID: {grant.agent_did}")
print(f"Scopes: {grant.scopes}")
print(f"Grant ID: {grant.grant_id}")
```

## Import

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions, GrantexTokenError
```

## Options

`VerifyGrantTokenOptions` configures how the token is verified:

| Field | Type | Required | Default | Description |
| - | - | - | - | - |
| `jwks_uri` | `str` | Yes | -- | URL of the JWKS endpoint (e.g. `https://api.grantex.dev/.well-known/jwks.json`). |
| `required_scopes` | `list[str] \| None` | No | `None` | If set, verification fails if the token is missing any of these scopes. |
| `clock_tolerance` | `int` | No | `0` | Seconds of leeway for `exp` and `iat` clock skew. |
| `audience` | `str \| None` | No | `None` | Expected `aud` claim. If `None`, audience is not validated. |
| `issuer` | `str \| None` | No | `None` | Expected `iss` claim. The hosted alias expects `https://grantex.dev`; custom JWKS URLs derive it when omitted. |
| `issuer_did` | `str \| None` | No | `None` | A `did:web` issuer. The JWKS is read from `https://<host>[:<port>][/<path>]/.well-known/jwks.json` instead of `jwks_uri`, and the expected issuer is that location without the suffix; an explicit `issuer` takes precedence. With `bounded_jwks_fetch=True` it must name a public domain (see [did:web issuers](#did-issuers)). |
| `bounded_jwks_fetch` | `bool` | No | `False` | Apply the [JWKS fetch limits](#jwks-fetch-limits) and the [did:web issuer](#did-issuers) checks. `False` by default in these releases; a later major release may make `True` the default, with `False` as the opt-out. Available in `grantex==0.6.1`. |
| `algorithms` | `list[str] \| None` | No | `None` | Narrow the accepted algorithms to a subset of `["RS256", "ES256"]`. Any other value raises `GrantexTokenError`. |
| `proof_jkt` | `str \| None` | No | `None` | Thumbprint of the key the caller proved possession of (for example with a verified DPoP proof); the token's `cnf.jkt` must match. The verifier does not check DPoP proofs. |
| `require_proof_of_possession` | `bool` | No | `False` | Fail closed unless `proof_jkt` is given and matches `cnf.jkt`. |
| `legacy_claims` | `bool` | No | `True` | Read legacy claim aliases (`agt`, `dev`, `grnt`, `scp`, `parentAgt`, `parentGrnt`, `delegationDepth`) when the standard claim is absent, with a `LegacyClaimsWarning`. The default becomes `False` in 0.7. With `False`, only standard claims are read and `typ` must be `at+jwt`. |

## Response

`verify_grant_token()` returns a `VerifiedGrant` frozen dataclass:

| Field | Type | Description |
| - | - | - |
| `token_id` | `str` | The JWT `jti` claim (unique token identifier). |
| `grant_id` | `str` | The grant identifier (`grnt` claim, falls back to `jti`). |
| `principal_id` | `str` | The user/principal who authorized the grant (`sub`). |
| `agent_did` | `str` | The agent's DID (`agt` claim). |
| `developer_id` | `str` | The developer who owns the agent (`dev` claim). |
| `scopes` | `tuple[str, ...]` | The granted permission scopes. |
| `issued_at` | `int` | Unix timestamp when the token was issued. |
| `expires_at` | `int` | Unix timestamp when the token expires. |
| `webauthn_evidence` | `WebAuthnGrantEvidence \| None` | Signed issuer reference to the original passkey assertion, when captured; not proof of current grant status. |
| `parent_agent_did` | `str \| None` | Parent agent DID (delegation chains only). |
| `parent_grant_id` | `str \| None` | Parent grant ID (delegation chains only). |
| `delegation_depth` | `int \| None` | Delegation depth (0 = root grant). |
| `authorization_details` | `Any` | The `authorization_details` claim, when present. |
| `act` | `Mapping \| None` | RFC 8693 actor chain (delegated grants). |
| `cnf` | `Mapping \| None` | Confirmation claim, e.g. `{"jkt": ...}`. |
| `audience` | `str \| list \| None` | The `aud` claim. |
| `legacy_claims_used` | `tuple[str, ...]` | Legacy aliases read because the standard claim was absent. |

Fields are read from the standard claims (`scope`, `client_id`, `act`, `urn:grantex:grant`) first;
see `spec/grant-token-0.6.md`.

## Algorithm

Tokens signed with **RS256** or **ES256** are accepted (`grantex.GRANT_TOKEN_ALGORITHMS`). `alg: none`, HS256 and every other algorithm are rejected, and `algorithms` can only narrow the list.

The key is the JWK Set entry named by the token's `kid` whose type matches the algorithm: an RSA key for RS256, an EC key on P-256 for ES256. A key published with a different `alg` or a `use` other than `sig` is never used, and there is no fallback to another key.

## JWKS fetch limits

<Note>
  `grantex==0.6.1` includes these limits and the [DID issuer](#did-issuers)
  checks when `bounded_jwks_fetch=True` is set. The option remains off by
  default; choose it explicitly when verifying untrusted remote issuers.
</Note>

The key set comes from whichever endpoint the verifier is pointed at. With
`bounded_jwks_fetch=True`, each fetch is bounded, with the same limits as the
TypeScript SDK's `boundedJwksFetch: true`:

```python theme={null}
grant = verify_grant_token(
    token,
    VerifyGrantTokenOptions(
        jwks_uri="https://issuer.example/.well-known/jwks.json",
        bounded_jwks_fetch=True,
    ),
)
```

| Limit | Value | Refused when |
| - | - | - |
| Status | `200` | Any other status. Redirects are not followed. |
| Media type | `application/json` or `application/jwk-set+json` | Any other `Content-Type`, a missing one, or a `charset` other than `utf-8`. |
| Encoding | `identity` | The SDK asks for an unencoded body and refuses a `Content-Encoding` such as `gzip`. |
| Size | 64 KiB | A larger `Content-Length`, or more bytes than that while reading. |
| Keys | 128 | A `keys` array with more entries. |
| Time | 5 seconds | The whole exchange, headers and body, takes longer. |

A refused fetch raises `GrantexTokenError` naming the endpoint and the reason,
and nothing is cached, so the next verification fetches again. The key-count
limit leaves room for the `grantex-YYYY-MM` aliases a self-hosted auth service
publishes (`JWT_LEGACY_KID_MONTHS`). `grantex.decisions.verify_decision_grant`
and `verify_decision_grants` take the same `bounded_jwks_fetch` keyword for
the keys they read from `jwks_uri`.

Without the option, the key set is read as in earlier releases: any `2xx`
response of any size, media type and number of keys, with a 10-second timeout
for each network operation. Bounded and unbounded key sets for the same URL
are cached separately.

## DID issuers

With `bounded_jwks_fetch=True`, `issuer_did` follows the did:web method
specification (§2.3, §2.5.2): the identifier after `did:web:` is a fully
qualified domain name, optionally followed by a port whose colon is
percent-encoded (`%3A`) and by path segments separated by colons. A DID is
written in ASCII (§3.5), so an internationalized domain name is given in its
IDNA A-label form, beginning `xn--`.

| `issuer_did` | JWKS URL | Expected issuer |
| - | - | - |
| `did:web:issuer.example` | `https://issuer.example/.well-known/jwks.json` | `https://issuer.example` |
| `did:web:issuer.example%3A8443` | `https://issuer.example:8443/.well-known/jwks.json` | `https://issuer.example:8443` |
| `did:web:issuer.example:tenants:acme` | `https://issuer.example/tenants/acme/.well-known/jwks.json` | `https://issuer.example/tenants/acme` |
| `did:web:xn--bcher-kva.example` | `https://xn--bcher-kva.example/.well-known/jwks.json` | `https://xn--bcher-kva.example` |

The DID decides whose keys are trusted, so the SDK raises `GrantexTokenError`,
before any request, for an IP address, `localhost` or a name under `.localhost`,
`.local`, `.home.arpa` or `.internal`, a single-label host, user information
(`@`), a port outside 1–65535, a path segment other than letters, digits, `.`,
`-` and `_` (or `.` / `..`), any character outside ASCII, and any value that is
not a `did:web` identifier. A non-ASCII name is refused rather than converted,
because conversion can turn a different character into an ASCII one (the
Kelvin sign, U+212A, into `k`) and so fetch keys from a host the DID does not spell.
It is never ignored in favour of `jwks_uri`. For a local or private issuer, set
`jwks_uri` and `issuer` instead. `issuer_did=None` means no DID. The TypeScript
SDK applies the same rules with `boundedJwksFetch: true`, and treats
`issuerDid: null` as `None`.

Without the option, `issuer_did` is read as in earlier releases: a value that
does not start with `did:web:` is ignored in favour of `jwks_uri`, and the rest
of the identifier is used as written, each `:` becoming `/`, with none of the
checks above (so a percent-encoded port is not decoded).

## Examples

### Basic Verification

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions

grant = verify_grant_token(
    token,
    VerifyGrantTokenOptions(
        jwks_uri="https://api.grantex.dev/.well-known/jwks.json",
    ),
)

print(f"Token ID: {grant.token_id}")
print(f"Grant ID: {grant.grant_id}")
print(f"Authorized by: {grant.principal_id}")
print(f"Agent: {grant.agent_did}")
print(f"Scopes: {grant.scopes}")
```

### Require Specific Scopes

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions, GrantexTokenError

try:
    grant = verify_grant_token(
        token,
        VerifyGrantTokenOptions(
            jwks_uri="https://api.grantex.dev/.well-known/jwks.json",
            required_scopes=["files:read", "files:write"],
        ),
    )
except GrantexTokenError as e:
    print(f"Verification failed: {e}")
    # e.g. "Grant token is missing required scopes: files:write"
```

### With Audience and Clock Tolerance

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions

grant = verify_grant_token(
    token,
    VerifyGrantTokenOptions(
        jwks_uri="https://api.grantex.dev/.well-known/jwks.json",
        audience="https://api.myservice.com",
        clock_tolerance=30,  # allow 30 seconds of clock skew
    ),
)
```

### Verifying Delegated Tokens

Delegated tokens include additional claims for the delegation chain:

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions

grant = verify_grant_token(
    delegated_token,
    VerifyGrantTokenOptions(
        jwks_uri="https://api.grantex.dev/.well-known/jwks.json",
    ),
)

if grant.delegation_depth is not None:
    print(f"This is a delegated token (depth: {grant.delegation_depth})")
    print(f"Parent agent: {grant.parent_agent_did}")
    print(f"Parent grant: {grant.parent_grant_id}")
else:
    print("This is a root grant token")
```

## Error Handling

`verify_grant_token()` raises `GrantexTokenError` in the following cases:

* The token header uses an algorithm other than RS256 or ES256, or one excluded by `algorithms`
* The JWKS endpoint is unreachable or returns invalid data
* With `bounded_jwks_fetch=True`, the JWKS response exceeds a [fetch limit](#jwks-fetch-limits): not `200`, not JSON, encoded, larger than 64 KiB, more than 128 keys, or slower than 5 seconds
* With `bounded_jwks_fetch=True`, `issuer_did` is not a usable [did:web issuer](#did-issuers)
* No key of the type the algorithm requires is found in the JWKS under the token's `kid`
* The token signature is invalid
* The token is expired
* The token issuer does not match the expected issuer
* The token audience does not match `audience`, when configured
* Required claims (`jti`, `sub`, `agt`, `dev`, `scp`, `iat`, `exp`) are missing
* Required scopes are not present in the token

```python theme={null}
from grantex import verify_grant_token, VerifyGrantTokenOptions, GrantexTokenError

try:
    grant = verify_grant_token(token, options)
except GrantexTokenError as e:
    print(f"Token verification failed: {e}")
```

## Ownership

Grantex is owned by Orchestrum Technologies LLP. Inventor and owner: Sanjeev Kumar. Ownership contact: [sanjeev@orchestrum.in](mailto:sanjeev@orchestrum.in) or [mishra.sanjeev@gmail.com](mailto:mishra.sanjeev@gmail.com).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.