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) 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) 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) 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, verify, encrypt and 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 and encryptMulti produce the General shape; the verify and decrypt side accepts both, and generalToFlattenedJWS and generalToFlattened convert one entry.

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.

TierJWSJWECovered by the token
Protectedprotected of each signatureprotectedyes
Shared unprotectednoneunprotectedno
Per-signer or per-recipientheader of each signatureheader of each recipientno

The tiers must be disjoint (RFC 7515 §7.2.1, RFC 7516 §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). 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). 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) are checked by verify and decrypt whenever the payload mode is "json":

ClaimMeaningChecked
expExpiration time, seconds since the epochAlways when present: ERR_JWT_EXPIRED once it has passed
nbfNot beforeAlways when present: ERR_JWT_NBF while it is in the future
iatIssued atMust be a number; with maxTokenAge, required and not older than that age
issIssuerOnly with issuer, which also makes it required
audAudienceOnly with audience, which also makes it required
subSubjectOnly with subject, which also makes it required
jtiToken idNot 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.

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) 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 can also be passed as a CryptoKey, as raw bytes for a secret, or as PEM through importPEM.

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.

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

ModeReturnsClaim checks
"json" (default)the claims object, typed JWTClaims or your verify<MyClaims> typeyes
"string"the payload as UTF-8 textno
"bytes"the payload as a Uint8Arrayno
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.