# JWT Inspector

> Decode a JWT, check expiry and algorithm, and verify the signature with a public key or JWKS.

Category: Inspect & Identify · Page: https://util.caruana.me/tools/jwt/

## API, MCP and A2A

### Decode and verify a JWT (`jwt_decode`)

`GET|POST https://util.caruana.me/api/jwt/decode` · MCP tool `jwt_decode` · A2A skill `jwt_decode` · [reference](https://util.caruana.me/reference/jwt_decode/)

Decodes a compact JWS (RFC 7515/7519): header, payload, and the registered claims, with `exp`, `nbf` and `iat` as ISO times and seconds relative to `at`. Flags expired, not-yet-valid, `alg: none`, weak or unusual algorithms, and a missing `exp`. A compact JWE (5 segments) is recognised and its header shown, but it cannot be decrypted. With `key`, verifies the signature using a PUBLIC key only: a JWK, a JWKS (selected by the token `kid`), a `PUBLIC KEY` PEM, or a `CERTIFICATE` PEM. HS256/HS384/HS512 cannot be verified here because they need a shared secret, which this service never accepts. The server never fetches keys: pass the JWKS JSON itself, not its URL. Decoding is not verification: never trust claims from a token whose `verification.outcome` is not `valid`.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string (≤ 65536 chars) | yes | The compact JWT (`header.payload.signature`, or 5 segments for JWE). A leading `Bearer `, surrounding quotes and embedded whitespace are removed, with a warning. |
| `key` | string (≤ 65536 chars) | no | Optional PUBLIC key to verify the signature: a JWK JSON object, a JWKS JSON (`{"keys":[...]}`), a `PUBLIC KEY` PEM, or a `CERTIFICATE` PEM. Private keys and symmetric (`kty: oct`) keys are refused. URLs are refused: fetch the JWKS yourself and pass its JSON. |
| `at` | string | no | Reference time for exp/nbf/iat, ISO 8601, e.g. `2026-01-01T00:30:00Z`. Default: now. |
| `clock_tolerance` | integer (0–86400) | no | Leeway in seconds for clock skew when evaluating exp, nbf and iat. |

Privacy: The server receives the token and, if given, a public key. It decodes and verifies them in memory and returns the result; the input is not stored or logged (only the per-call metadata row is recorded). The server makes no network requests (it never fetches `jku`, `x5u` or JWKS URLs). Tokens are bearer credentials: a token that has not expired grants access to whoever holds it, so prefer the page (which decodes in your browser) for live production tokens, or send tokens that are expired or from test systems.


## User guide

The tool sits in a window that works like a desktop app: menus on the top row and the **full-screen** icon at its right (**Esc** leaves full screen). The set-up options described below are in **Settings → Options…**, an overlay, and the **Settings** menu also lists the common choices directly. To keep the options in view instead, choose **View → Inline config**; the page remembers that choice. **Help → Keyboard shortcuts…** lists the keys.

1. **Paste the token.** A leading `Bearer `, surrounding quotes and line breaks are removed for you, with a note.
2. Read the **summary card**: the algorithm, issuer, subject, audience, and the three time claims with how long ago or how far ahead they are. Red pills flag an expired token, one not yet valid, `alg: none`, an unusual algorithm, or a missing `exp`.
3. To **verify the signature**, paste the issuer's public key: its JWKS JSON (from the `jwks_uri` in its OpenID configuration), a single JWK, a `PUBLIC KEY` PEM, or the signing certificate. The banner turns green only when the signature verifies.

**HMAC tokens (HS256, HS384, HS512)** are signed with a shared secret, not a key pair. Open _Verify … with a shared secret_ and type the secret: it is checked in this tab with Web Crypto and never sent anywhere. The API cannot do this (see below).

> **Decoding is not verifying.** Anyone can create a token with any claims. Until the banner says _Signature valid_ with the issuer's key, treat every claim as untrusted input.

**Tokens are credentials.** A token that has not expired lets whoever holds it act as its subject. This page decodes it locally; be careful where else you paste it.

## Specification

**Formats.**

- **JWS compact serialization** (RFC 7515 §7.1): three base64url segments, `header.payload.signature`. The header must be a JSON object. If the payload is a JSON object it is the JWT claims set (RFC 7519); otherwise it is shown as text (`payloadText`).
- **JWE compact serialization** (RFC 7516 §7.1): five segments. Only the protected header is decoded. The content cannot be read without the recipient's key, which the API never accepts.
- **Encoding:** segments must be unpadded base64url (RFC 7515 §2). A token containing `+`, `/` or `=` is rejected with an explanation, because conforming verifiers reject it too.
- **Not supported:** JWS/JWE JSON serialization. `"b64": false` (RFC 7797) payloads are shown as text.
- **Duplicate JSON members:** the last one wins (`JSON.parse` behaviour).

**Registered claims** (RFC 7519 §4.1).

| Claim               | Output                                                                                                          |
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
| `iss`, `sub`, `jti` | String, or `null` with a warning if it has another type                                                         |
| `aud`               | Always an array: a single string becomes a one-item array                                                       |
| `exp`, `nbf`, `iat` | `value` (seconds, as given), `iso` (UTC), and `secondsFromAt` = value − reference time; negative is in the past |

A NumericDate that is not a JSON number is ignored with a warning. A value above 10¹¹ triggers a warning that it is probably in milliseconds.

**Status flags**, evaluated at the reference time `at` (default: now) with `clock_tolerance` seconds of leeway (default 0):

| Flag               | Rule                                                                                               |
| ------------------ | -------------------------------------------------------------------------------------------------- |
| `expired`          | `at` ≥ `exp` + tolerance. The token is invalid _at_ the `exp` instant (RFC 7519 §4.1.4)            |
| `notYetValid`      | `at` < `nbf` − tolerance                                                                           |
| `issuedInFuture`   | `at` < `iat` − tolerance                                                                           |
| `missingExp`       | The payload is a claims set without `exp`                                                          |
| `algNone`          | `alg` is `none` (RFC 7518 §3.6): unsecured, anyone can forge it                                    |
| `weakAlgorithm`    | `alg` is `none`, or names SHA-1 or MD5 (for example `RS1`, `HS1`)                                  |
| `unusualAlgorithm` | `alg` is not registered (including the wrong case, such as `hs256`), or is ES256K, Ed448 or ML-DSA |
| `symmetric`        | HS256/HS384/HS512                                                                                  |

**Other checks reported as warnings.**

- **Signature length:** HS*, ES*, Ed25519 and Ed448 signatures have a fixed size. A wrong length is flagged; for ECDSA this usually means the signature was DER-encoded instead of raw `r‖s` (RFC 7518 §3.4).
- **Key-location headers:** `jku` and `x5u` are never fetched, and an embedded `jwk` or `x5c` is never used to verify. Trusting a key that the token itself supplies is a known attack.
- **`crit` extensions** are listed. Unknown critical extensions make verification fail, as RFC 7515 §4.1.11 requires.

**Signature verification.**

- **Key formats:** a JWK, a JWKS (`{"keys": [...]}`, or a bare array), a `PUBLIC KEY` (SPKI) PEM, or a `CERTIFICATE` PEM. Certificate validity, chain and revocation are not checked; only its public key is used.
- **JWKS key selection:** when the token has a `kid`, only keys with that `kid` are considered. Then a key must have the right `kty` and curve for the algorithm, `alg` absent or equal to the token's, and `use` not `enc`. Every remaining key is tried, and the first that verifies wins.
- **Algorithm:** the token's own `alg` is the only one allowed. jose rejects RSA keys smaller than 2048 bits.
- **Supported algorithms:** RS256/384/512, PS256/384/512, ES256/384/512, EdDSA (Ed25519 keys) and Ed25519. ML-DSA-44/65/87 work only where the runtime's Web Crypto supports them. ES256K and Ed448 are reported as unverifiable.
- **Outcomes:** `valid`; `invalid-signature` (the key does not match: the token was altered, or a different key signed it); or `unverifiable`, with an `error` explaining why (no key with that `kid`, wrong key type, `alg: none`, HMAC, unsupported algorithm).

**Why HMAC tokens cannot be verified through the API.** HS256/384/512 use one shared secret both to sign and to verify, so anyone who can verify can also forge tokens. This service never accepts secrets:

- `key` values with `kty: "oct"` or a `k` member are refused.
- So are private JWKs (`d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`, `priv`) and PEM blocks labelled `PRIVATE KEY`.

If a token says `HS256` but the issuer normally signs with RS256 or ES256, it may be an algorithm-confusion attack: the attacker signs with the public key used as an HMAC secret. Reject it.

**No network access.** The API and the page never fetch anything, so `key` cannot be a URL. To verify against an issuer's keys:

1. Fetch `https://<issuer>/.well-known/openid-configuration` yourself.
2. Take its `jwks_uri` and fetch that.
3. Pass the JSON you got as `key`.

**Limits.** API `token` and `key`: at most 65,536 characters each.
