> ## 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 grant tokens locally with published JWKS keys, without a per-token Grantex verification API call.

## Overview

`verifyGrantToken()` is a standalone function that verifies signatures and claims
locally after retrieving the published JWKS (JSON Web Key Set). It validates the
RS256 or ES256 signature, issuer, expiry, and optional scopes and audience. The current
standalone helper resolves the remote JWKS on each invocation, so it is not a
network-free hot path.

Use it when signature-and-claim validation without a revocation lookup is the
right trade-off. It avoids `POST /v1/tokens/verify`, but the JWKS endpoint must be
reachable for each standalone helper call.

```typescript theme={null}
import { verifyGrantToken } from '@grantex/sdk';

const grant = await verifyGrantToken(token, {
  jwksUri: 'https://api.grantex.dev/.well-known/jwks.json',
  requiredScopes: ['calendar:read'],
});

console.log(grant.principalId); // The user who authorized this agent
console.log(grant.agentDid);    // The agent's DID
console.log(grant.scopes);      // All granted scopes
```

<Note>
  The standalone helper may contact the JWKS endpoint when it resolves keys.
  Keep that endpoint reachable from the verifying service; token validation
  itself is performed locally and does not call `POST /v1/tokens/verify`.
</Note>

<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 `issuerDid` explicitly.
</Note>

## Import

```typescript theme={null}
import { verifyGrantToken } from '@grantex/sdk';
```

This function does not require a `Grantex` client instance.

## Parameters

<ParamField body="token" type="string" required>
  The grant token JWT string to verify.
</ParamField>

<ParamField body="options" type="VerifyGrantTokenOptions" required>
  Verification options.
</ParamField>

### VerifyGrantTokenOptions

<ParamField body="jwksUri" type="string" required>
  The JWKS endpoint URL. For the hosted service, use `https://api.grantex.dev/.well-known/jwks.json`.
</ParamField>

<ParamField body="requiredScopes" type="string[]">
  If provided, the function throws `GrantexTokenError` when the token is missing any of these scopes.
</ParamField>

<ParamField body="audience" type="string">
  Expected `aud` claim. If provided, verification fails when the token's audience does not match.
</ParamField>

<ParamField body="issuer" type="string">
  Expected `iss` claim. The hosted JWKS alias automatically expects
  `https://grantex.dev`; custom JWKS URLs derive the issuer from their URL when
  this option is omitted.
</ParamField>

<ParamField body="issuerDid" type="string | null">
  A `did:web` issuer identifier. When provided, the JWKS is read from
  `https://<host>[:<port>][/<path>]/.well-known/jwks.json` instead of `jwksUri`,
  and the expected issuer is that location without the suffix. An explicit
  `issuer` still takes precedence. With `boundedJwksFetch: true` it must name a
  public domain; see [DID issuers](#did-issuers). `null`, like leaving it out,
  means no DID, as `issuer_did=None` does in the Python SDK.
</ParamField>

<ParamField body="boundedJwksFetch" type="boolean">
  Apply the [JWKS fetch limits](#jwks-fetch-limits) and the
  [DID issuer](#did-issuers) checks. Defaults to `false` in these releases; a
  later major release may make `true` the default, with `false` as the opt-out.
  Available in `@grantex/sdk@0.7.1`.
</ParamField>

<ParamField body="clockTolerance" type="number">
  Optional clock-skew tolerance, in seconds, for time-based JWT claims.
</ParamField>

<ParamField body="proofJkt" type="string">
  Thumbprint of the key the caller proved possession of (for example with a verified DPoP
  proof). The token's `cnf.jkt` must match it. The verifier does not check DPoP proofs.
</ParamField>

<ParamField body="requireProofOfPossession" type="boolean">
  Fail closed unless `proofJkt` is given and matches `cnf.jkt`. Without it, `cnf` is returned but not enforced.
</ParamField>

<ParamField body="legacyClaims" type="boolean">
  Read legacy claim aliases (`agt`, `dev`, `grnt`, `scp`, `parentAgt`, `parentGrnt`,
  `delegationDepth`) when the standard claim is absent, emitting a `DeprecationWarning`
  (code `GRANTEX_LEGACY_CLAIM`) once per alias. Defaults to `true` in 0.6 and `false` in 0.7.
  With `false`, only standard claims are read and `typ` must be `at+jwt`. A token whose
  standard claim and alias disagree is always rejected.
</ParamField>

<ParamField body="algorithms" type="Array<'RS256' | 'ES256'>">
  Narrow the accepted signature algorithms. The default is `GRANT_TOKEN_ALGORITHMS`
  (`['RS256', 'ES256']`); any other value throws `GrantexTokenError`. The key is the
  JWK Set entry named by `kid` whose type matches the algorithm (RSA for RS256, EC
  P-256 for ES256); `alg: none` and HS256 are always rejected.
</ParamField>

## JWKS fetch limits

<Note>
  `@grantex/sdk@0.7.1` includes these limits and the [DID issuer](#did-issuers)
  checks when `boundedJwksFetch: 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
`boundedJwksFetch: true`, each fetch is bounded, with the same limits as the
Python SDK's `bounded_jwks_fetch=True`:

```typescript theme={null}
const grant = await verifyGrantToken(token, {
  jwksUri: 'https://issuer.example/.well-known/jwks.json',
  boundedJwksFetch: 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`. |
| Size | 64 KiB | A larger `Content-Length`, or more bytes than that while reading. A compressed response is measured by its decoded size. |
| Keys | 128 | A `keys` array with more entries. |
| Time | 5000 ms | The whole exchange, headers and body, takes longer. |

A refused fetch throws `GrantexTokenError` naming the endpoint and the reason,
and nothing is cached, so the next verification fetches again. JOSE's cache
and refresh behaviour is unchanged. The key-count limit leaves room for the
`grantex-YYYY-MM` aliases a self-hosted auth service publishes
(`JWT_LEGACY_KID_MONTHS`). Decision-grant verification (`verifyDecisionGrant`,
`verifyDecisionGrants`) with `jwksUri` and `boundedJwksFetch: true` reads keys
within the same limits.

Without the option, the key set is read as in earlier releases, with JOSE's
`createRemoteJWKSet` defaults: an HTTP 200 of any size, media type and number
of keys, within JOSE's 5-second timeout. Bounded and unbounded key sets for the
same URL are cached separately.

## DID issuers

With `boundedJwksFetch: true`, `issuerDid` 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--`.

| `issuerDid` | 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 throws `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 `jwksUri`. For a local or private issuer, set
`jwksUri` and `issuer` instead. The Python SDK applies the same rules with
`bounded_jwks_fetch=True`.

Without the option, `issuerDid` is read as in earlier releases: a value that
does not start with `did:web:` is ignored in favour of `jwksUri`, 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).

## Response: `VerifiedGrant`

<ResponseField name="tokenId" type="string">
  Unique token ID (the `jti` JWT claim).
</ResponseField>

<ResponseField name="grantId" type="string">
  The grant record ID (from the `grnt` claim, falls back to `jti`).
</ResponseField>

<ResponseField name="principalId" type="string">
  The end-user who authorized the agent (the `sub` claim).
</ResponseField>

<ResponseField name="agentDid" type="string">
  The agent's decentralized identifier (the `agt` claim).
</ResponseField>

<ResponseField name="developerId" type="string">
  The developer organization that owns the agent (the `dev` claim).
</ResponseField>

<ResponseField name="scopes" type="string[]">
  The scopes granted to the agent (the `scp` claim).
</ResponseField>

<ResponseField name="issuedAt" type="number">
  Token issued-at timestamp in seconds since the Unix epoch.
</ResponseField>

<ResponseField name="expiresAt" type="number">
  Token expiry timestamp in seconds since the Unix epoch.
</ResponseField>

<ResponseField name="webauthnEvidence" type="WebAuthnGrantEvidence | undefined">
  Signed issuer reference to the original passkey assertion, when portable
  evidence was captured. This does not replace online revocation checks or
  independent verification of the opt-in VC's assertion.
</ResponseField>

<ResponseField name="parentAgentDid" type="string">
  The parent agent's DID, present only for delegated grants.
</ResponseField>

<ResponseField name="parentGrantId" type="string">
  The parent grant ID, present only for delegated grants.
</ResponseField>

<ResponseField name="delegationDepth" type="number">
  The delegation depth (`0` = root grant, `1` = first-level delegation, etc.). Present only for delegated grants.
</ResponseField>

## Error handling

`verifyGrantToken()` throws `GrantexTokenError` in the following cases:

* The JWT signature is invalid
* The token uses an algorithm other than RS256 or ES256, or names a key of the wrong type
* The token has expired
* The token issuer does not match the expected issuer
* Required claims (`jti`, `sub`, `agt`, `dev`, `scp`, `iat`, `exp`) are missing
* The token is missing one or more `requiredScopes`
* The `audience` does not match
* With `boundedJwksFetch: true`, the JWKS response exceeds a [fetch limit](#jwks-fetch-limits): not `200`, not JSON, larger than 64 KiB, more than 128 keys, or slower than 5000 ms
* With `boundedJwksFetch: true`, `issuerDid` is not a usable [did:web issuer](#did-issuers)

```typescript theme={null}
import { verifyGrantToken, GrantexTokenError } from '@grantex/sdk';

try {
  const grant = await verifyGrantToken(token, {
    jwksUri: 'https://api.grantex.dev/.well-known/jwks.json',
    requiredScopes: ['payments:initiate'],
  });
  // Token is valid -- proceed
} catch (err) {
  if (err instanceof GrantexTokenError) {
    console.error('Token verification failed:', err.message);
    // e.g. "Grant token is missing required scopes: payments:initiate"
  }
}
```

## JWT claims mapping

The following table shows how JWT claims map to `VerifiedGrant` fields:

| JWT Claim | VerifiedGrant Field | Description |
| - | - | - |
| `jti` | `tokenId` | Unique token identifier |
| `grnt` | `grantId` | Grant record ID (falls back to `jti`) |
| `sub` | `principalId` | End-user identifier |
| `agt` | `agentDid` | Agent decentralized identifier |
| `dev` | `developerId` | Developer organization ID |
| `scp` | `scopes` | Granted scopes array |
| `iat` | `issuedAt` | Issued-at timestamp (epoch seconds) |
| `exp` | `expiresAt` | Expiry timestamp (epoch seconds) |
| `parentAgt` | `parentAgentDid` | Parent agent DID (delegation) |
| `parentGrnt` | `parentGrantId` | Parent grant ID (delegation) |
| `delegationDepth` | `delegationDepth` | Delegation chain depth |

## Comparison with online verification

| | `verifyGrantToken()` | `tokens.verify()` |
| - | - | - |
| **Network call** | May fetch keys from the JWKS endpoint | Calls Grantex API |
| **Latency** | Local cryptography plus JWKS retrieval when needed | Network round-trip |
| **Revocation check** | No (checks signature + expiry only) | Yes (checks revocation status) |
| **Requires API key** | No | Yes |
| **Use case** | Services verifying tokens at high throughput | Admin dashboards, token status checks |

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