
# Concepts

> The ideas every other page assumes: signed and encrypted tokens, their two wire formats, where header parameters live, claims and time, keys, and payload modes.

## JWS and JWE

A **JWS** ([RFC 7515](https://www.rfc-editor.org/rfc/rfc7515)) is a signed token. Anyone can read its payload. Only the holder of the signing key could have produced the signature, so a verifier knows the payload is unchanged and who made it.

A **JWE** ([RFC 7516](https://www.rfc-editor.org/rfc/rfc7516)) is an encrypted token. Only the holder of the decryption key can read the payload. Every JWE algorithm unjwt supports is authenticated, so a changed byte fails decryption.

A **JWT** ([RFC 7519](https://www.rfc-editor.org/rfc/rfc7519)) is either of the two when its payload is a JSON object of claims. `sign` and `encrypt` also accept a string or bytes as payload, which gives a valid JOSE token that is not a JWT.

Sign when the client may read the data and you need to trust it when it comes back. Encrypt when the client, or anything between you and it, must not read the data.

## Compact and JSON serialisation

The **compact** form is the dotted string most people mean by "a JWT". It carries one signature or one recipient, and its whole header is protected.

```
JWS: <protected header>.<payload>.<signature>
JWE: <protected header>.<encrypted key>.<iv>.<ciphertext>.<tag>
```

[`sign`](/jwt/jws/sign), [`verify`](/jwt/jws/verify), [`encrypt`](/jwt/jwe/encrypt) and [`decrypt`](/jwt/jwe/decrypt) work on the compact form.

The **JSON** form is an object. The **General** shape holds a list, `signatures[]` for a JWS or `recipients[]` for a JWE, so one payload can carry several signers or be readable by several recipients. The **Flattened** shape is the same object with a single entry inlined. [`signMulti`](/jwt/jws/multi) and [`encryptMulti`](/jwt/jwe/multi) produce the General shape; the verify and decrypt side accepts both, and `generalToFlattenedJWS` and `generalToFlattened` convert one entry.

```ts
import { signMulti, verifyMulti } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";

const a = await generateJWK("ES256", { kid: "a" });
const b = await generateJWK("Ed25519", { kid: "b" });

const jws = await signMulti({ sub: "user_1" }, [
  { key: a.privateKey },
  { key: b.privateKey, unprotectedHeader: { "x-note": "hello" } },
]);
// {
//   payload: "eyJzdWIiOiJ1c2VyXzEifQ",
//   signatures: [
//     { protected: "eyJraWQiOiJhIiwiYWxnIjoiRVMyNTYi…", signature: "Ewywi9cC…" },
//     { protected: "eyJraWQiOiJiIiwiYWxnIjoiRWQyNTUx…", signature: "Mz8CM003…", header: { "x-note": "hello" } },
//   ],
// }

const result = await verifyMulti(jws, b.publicKey);
// result.signerIndex:     1
// result.protectedHeader: { kid: "b", alg: "Ed25519", typ: "JWT" }
// result.signerHeader:    { "x-note": "hello" }
```

## Header tiers

The JSON form splits header parameters into tiers. Only the protected tier is covered by the signature or the encryption; the others can be changed in transit without breaking anything.

| Tier                        | JWS                           | JWE                        | Covered by the token |
| --------------------------- | ----------------------------- | -------------------------- | -------------------- |
| Protected                   | `protected` of each signature | `protected`                | yes                  |
| Shared unprotected          | none                          | `unprotected`              | no                   |
| Per-signer or per-recipient | `header` of each signature    | `header` of each recipient | no                   |

The tiers must be disjoint ([RFC 7515 §7.2.1](https://www.rfc-editor.org/rfc/rfc7515#section-7.2.1), [RFC 7516 §7.2.1](https://www.rfc-editor.org/rfc/rfc7516#section-7.2.1)). A name in two tiers is `ERR_JWS_HEADER_PARAMS_NOT_DISJOINT` or `ERR_JWE_HEADER_PARAMS_NOT_DISJOINT`.

Some parameters only mean something when they are protected, so unjwt reads them from the protected tier alone:

- **`crit`** lists extensions the reader must understand ([RFC 7515 §4.1.11](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.11)). Outside the protected header it is refused: `ERR_INVALID_OPTION` when you produce a token, `ERR_JWS_INVALID` or `ERR_JWE_INVALID` when you read one. Every name it lists must be present in the protected header, and must be one the library processes or you pass in `recognizedHeaders`.
- **`b64`** switches a JWS to an unencoded payload ([RFC 7797 §3](https://www.rfc-editor.org/rfc/rfc7797#section-3)). It is refused outside the protected header, the same way as `crit`.
- **`typ`** names the kind of token. The `typ` option of `verify` and `decrypt` is matched against the protected header only, so a `typ` in an unprotected tier does not satisfy it.

Other parameters are read from whichever tier carries them. `alg` and `kid` can sit in any tier; `encryptMulti` writes them per recipient. A JWE's `enc` is read from the protected or shared unprotected header, never from a recipient's own header. A result keeps the tiers apart: `protectedHeader` holds only what was protected, and the rest comes back as `signerHeader`, `sharedUnprotectedHeader` or `recipientHeader`.

## Claims and time

Claims are the members of a JWT payload. The registered ones ([RFC 7519 §4.1](https://www.rfc-editor.org/rfc/rfc7519#section-4.1)) are checked by `verify` and `decrypt` whenever the payload mode is `"json"`:

| Claim | Meaning                                  | Checked                                                                    |
| ----- | ---------------------------------------- | -------------------------------------------------------------------------- |
| `exp` | Expiration time, seconds since the epoch | Always when present: `ERR_JWT_EXPIRED` once it has passed                  |
| `nbf` | Not before                               | Always when present: `ERR_JWT_NBF` while it is in the future               |
| `iat` | Issued at                                | Must be a number; with `maxTokenAge`, required and not older than that age |
| `iss` | Issuer                                   | Only with `issuer`, which also makes it required                           |
| `aud` | Audience                                 | Only with `audience`, which also makes it required                         |
| `sub` | Subject                                  | Only with `subject`, which also makes it required                          |
| `jti` | Token id                                 | Not checked; the session adapters use it as the session id                 |

`sign` and `encrypt` compute the time claims for you. `expiresIn` or `expiresAt` sets `exp`, `notBeforeIn` or `notBeforeAt` sets `nbf`, and either one sets `iat` when the payload has none. A claim already in the payload is kept as given. Without any of those options, no time claim is added.

```ts
import { sign, verify, isJWTError } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";

const key = await generateJWK("HS256");
const noon = new Date("2026-10-15T12:00:00Z");

const token = await sign({ sub: "user_1" }, key, {
  currentDate: noon,
  expiresIn: "1h",
  notBeforeIn: "5m",
});
await verify(token, key, { currentDate: new Date("2026-10-15T12:10:00Z") });
// { payload: { sub: "user_1", iat: 1792065600, exp: 1792069200, nbf: 1792065900 }, … }

const early = new Date("2026-10-15T12:04:00Z");
await verify(token, key, { currentDate: early }).catch((error) => isJWTError(error) && error.code);
// "ERR_JWT_NBF"
await verify(token, key, { currentDate: early, clockTolerance: "2m" });
// { payload: { sub: "user_1", … }, … }
```

Every duration option takes a `Duration`: an integer number of seconds, or an integer followed by a unit with no space. The units are `s`, `m`, `h`, `D`, `W`, `M` (30 days) and `Y` (365 days), or their spelled forms such as `"minutes"` and `"days"`. Case matters: `"5m"` is five minutes and `"5M"` is five months. Anything else is `ERR_INVALID_OPTION`.

`clockTolerance` allows for clocks that disagree between machines. It takes seconds or a `Duration`, defaults to `0`, and widens the `exp`, `nbf` and `iat` checks by that much.

## Keys

A **JWK** ([RFC 7517](https://www.rfc-editor.org/rfc/rfc7517)) is a key written as JSON. `kty` names the family (`oct` for a shared secret, `RSA`, `EC`, `OKP`), `alg` names the one algorithm the key is for, and `kid` names the key. unjwt treats a JWK's `alg` as binding: a key that says `A256KW` wraps keys and does nothing else. A [JWK](/jwk) can also be passed as a `CryptoKey`, as raw bytes for a secret, or as PEM through [`importPEM`](/jwk/pem).

A **JWK Set** is `{ keys: [...] }`, the document a server publishes at an address such as `/.well-known/jwks.json` so others can verify its tokens. A set is public-key material: `oct` entries are skipped unless you pass `allowSymmetricJWKSet: true` for a set you control.

The **`kid`** in a token's header is a hint. With a JWK Set, it selects the candidate keys, and a `kid` with no match is `ERR_JWK_KEY_NOT_FOUND`. A token without a `kid` tries every candidate whose `alg`, `use` and `key_ops` fit, up to `limits.keyCandidates` (8 by default). With a single key, a different `kid` does not stop verification; the key is tried and the signature decides.

```ts
import { sign, verify } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";

const september = await generateJWK("ES256", { kid: "2026-09" });
const october = await generateJWK("ES256", { kid: "2026-10" });
const jwks = { keys: [september.publicKey, october.publicKey] };

const token = await sign({ sub: "user_1" }, october.privateKey);
await verify(token, jwks);
// { payload: { sub: "user_1" }, protectedHeader: { kid: "2026-10", alg: "ES256", typ: "JWT" } }

const unknown = await sign({ sub: "user_1" }, october.privateKey, {
  protectedHeader: { kid: "2027-01" },
});
await verify(unknown, jwks);
// throws ERR_JWK_KEY_NOT_FOUND: No key found in JWK Set with kid "2027-01".
await verify(unknown, october.publicKey);
// { payload: { sub: "user_1" }, protectedHeader: { kid: "2027-01", alg: "ES256", typ: "JWT" } }
```

A **thumbprint** ([RFC 7638](https://www.rfc-editor.org/rfc/rfc7638)) is a SHA-256 hash of a key's required public members, base64url-encoded. It names a key by its content, so the same key always gets the same name. [`generateJWK`](/jwk/generate) uses it as the default `kid`, and both halves of a pair share it. Pass `kid: "2026-10"` to set your own, or `kid: false` for none. [`thumbprint(jwk)`](/jwk/generate) computes it for any key.

## Payload modes

The `payload` option of `verify` and `decrypt` says what you expect inside the token. The token never decides.

| Mode               | Returns                                                              | Claim checks |
| ------------------ | -------------------------------------------------------------------- | ------------ |
| `"json"` (default) | the claims object, typed `JWTClaims` or your `verify<MyClaims>` type | yes          |
| `"string"`         | the payload as UTF-8 text                                            | no           |
| `"bytes"`          | the payload as a `Uint8Array`                                        | no           |

```ts
import { sign, verify } from "unjwt/jws";
import { generateJWK } from "unjwt/jwk";

const key = await generateJWK("HS256");
const token = await sign({ sub: "user_1" }, key);

(await verify(token, key)).payload; // { sub: "user_1" }
(await verify(token, key, { payload: "string" })).payload; // '{"sub":"user_1"}'
(await verify(token, key, { payload: "bytes" })).payload; // Uint8Array(16) [123, 34, 115, …]

const textToken = await sign("hello", key);
await verify(textToken, key);
// throws ERR_JWS_INVALID: Invalid JWS: Payload is not a JSON object.
```

`"string"` must be valid UTF-8 and `"json"` must be a JSON object; anything else is `ERR_JWS_INVALID` or `ERR_JWE_INVALID`. The return type follows a literal mode, so `payload: "bytes"` types `payload` as `Uint8Array`.
