Errors

One class, one code set. Branch on error.code with isJWTError, never on the message.

Note

ELI5. When unjwt refuses a token, a key or an option, it throws one kind of error, a JWTError, and that error carries a short label such as ERR_JWT_EXPIRED. The label says what went wrong. The sentence next to it is for a human reading a log and may be reworded in any release. So your code reads the label. Some calls never throw for bad input. A type guard answers false, and a session with a bad cookie comes back empty.

JWTError and isJWTError are exported from unjwt, unjwt/jws, unjwt/jwe and unjwt/jwk. The adapter entry points do not re-export them, so a session handler imports them from one of those four.

#Signature

type KnownJWTErrorCode =
  | "ERR_JWT_EXPIRED"
  | "ERR_JWT_NBF"
  | "ERR_JWT_CLAIM_INVALID"
  | "ERR_JWT_CLAIM_MISSING"
  | "ERR_JWS_INVALID"
  | "ERR_JWS_INVALID_SERIALIZATION"
  | "ERR_JWS_SIGNATURE_INVALID"
  | "ERR_JWS_ALG_NOT_ALLOWED"
  | "ERR_JWS_ALG_MISSING"
  | "ERR_JWS_SIGNER_ALG_INFERENCE"
  | "ERR_JWS_NO_MATCHING_SIGNER"
  | "ERR_JWS_HEADER_PARAMS_NOT_DISJOINT"
  | "ERR_JWS_B64_INCONSISTENT"
  | "ERR_JWE_INVALID"
  | "ERR_JWE_INVALID_SERIALIZATION"
  | "ERR_JWE_DECRYPTION_FAILED"
  | "ERR_JWE_ALG_NOT_ALLOWED"
  | "ERR_JWE_ALG_MISSING"
  | "ERR_JWE_ALG_FORBIDDEN_IN_MULTI"
  | "ERR_JWE_ENC_MISSING"
  | "ERR_JWE_RECIPIENT_ALG_INFERENCE"
  | "ERR_JWE_NO_MATCHING_RECIPIENT"
  | "ERR_JWE_HEADER_PARAMS_NOT_DISJOINT"
  | "ERR_JWK_INVALID"
  | "ERR_JWK_KEY_NOT_FOUND"
  | "ERR_JWK_UNSUPPORTED"
  | "ERR_SESSION_TOKEN_INVALID"
  | "ERR_SESSION_READONLY_EVENT"
  | "ERR_SESSION_KEY_INVALID"
  | "ERR_SESSION_CONFIG_INVALID"
  | "ERR_INVALID_OPTION";

type JWTErrorCode = KnownJWTErrorCode | (string & {});

interface JWTErrorCauseMap {
  ERR_JWT_EXPIRED: { jti?: string; iat?: number; exp?: number };
}

class JWTError<TCode extends JWTErrorCode = JWTErrorCode> extends Error {
  readonly name: "JWTError";
  readonly code: TCode;
  readonly cause?: unknown;
  constructor(message: string, code: TCode, cause?: unknown);
}

function isJWTError<T extends keyof JWTErrorCauseMap>(
  error: unknown,
  code: T,
): error is JWTError<T> & { cause: JWTErrorCauseMap[T] };
function isJWTError<T extends KnownJWTErrorCode>(error: unknown, code: T): error is JWTError<T>;
function isJWTError(error: unknown): error is JWTError;

KnownJWTErrorCode is the closed list this release throws. JWTErrorCode, the type of code, also admits any other string, so a newer unjwt can add a code without breaking your types. Keep a fallback branch for codes you do not handle.

#The codes

CodeMeaningRaised by
ERR_JWT_EXPIREDexp has passed, or the token is older than maxTokenAge.verify, decrypt, verifyMulti, decryptMulti, validateJwtClaims, session verify*/unseal*; given to the onExpire hook on a session read.
ERR_JWT_NBFnbf is in the future.The same claim checks.
ERR_JWT_CLAIM_INVALIDiss, sub or aud differs from the expectation; exp, nbf or iat is not a non-negative number; iat is in the future. Also both expiresIn and expiresAt given to a sign.Claim checks; sign, encrypt.
ERR_JWT_CLAIM_MISSINGA claim the options require is absent.Claim checks.
ERR_JWS_INVALIDThe token is malformed: wrong number of parts, a segment that is not canonical base64url, a bad header, a typ mismatch, a crit or b64 violation, too many signatures.verify, verifyMulti, verifyMultiAll, sign (b64: false payload with a dot).
ERR_JWS_INVALID_SERIALIZATIONA JSON JWS that is neither General nor Flattened.verifyMulti, verifyMultiAll, generalToFlattenedJWS.
ERR_JWS_SIGNATURE_INVALIDThe signature does not verify under the key.verify, verifyMulti.
ERR_JWS_ALG_NOT_ALLOWEDalg is none, unknown, outside algorithms, or no allowlist can be inferred from the key.verify, verifyMulti, sign, signMulti.
ERR_JWS_ALG_MISSINGsign cannot tell which algorithm to use.sign.
ERR_JWS_SIGNER_ALG_INFERENCEA signer's key carries no usable alg.signMulti.
ERR_JWS_NO_MATCHING_SIGNERstrictSignerMatch found no signature the key could have produced.verifyMulti.
ERR_JWS_HEADER_PARAMS_NOT_DISJOINTA name sits in both the protected and the unprotected header of a signature.signMulti, verifyMulti.
ERR_JWS_B64_INCONSISTENTSigners disagree on b64 (RFC 7797 §3).signMulti.
ERR_JWE_INVALIDThe token or a header is malformed, enc is unsupported, a crit rule fails, a limit is exceeded.decrypt, decryptMulti, encrypt, encryptMulti, wrapKey, unwrapKey.
ERR_JWE_INVALID_SERIALIZATIONA JSON JWE that is neither General nor Flattened.decryptMulti, encryptMulti, generalToFlattened.
ERR_JWE_DECRYPTION_FAILEDKey unwrap or content decryption failed. One code for every cause (RFC 7516 §11.5).decrypt, decryptMulti, unwrapKey.
ERR_JWE_ALG_NOT_ALLOWEDalg or enc is none, unknown, or outside algorithms / encryptionAlgorithms.decrypt, decryptMulti, encrypt, encryptMulti.
ERR_JWE_ALG_MISSINGencrypt cannot tell which key management algorithm to use.encrypt.
ERR_JWE_ENC_MISSINGalg: "dir" without an enc.encrypt.
ERR_JWE_ALG_FORBIDDEN_IN_MULTIdir or bare ECDH-ES given to a multi-recipient message.encryptMulti.
ERR_JWE_RECIPIENT_ALG_INFERENCEA recipient key is not a JWK or carries no alg.encryptMulti.
ERR_JWE_NO_MATCHING_RECIPIENTstrictRecipientMatch found no recipient the key could open.decryptMulti.
ERR_JWE_HEADER_PARAMS_NOT_DISJOINTA name sits in more than one header tier.encryptMulti, decryptMulti.
ERR_JWK_INVALIDKey material is malformed or does not fit the algorithm.importKey, exportKey, importPEM, thumbprint, assertCryptoKey, and every sign, verify, encrypt or decrypt given such a key.
ERR_JWK_KEY_NOT_FOUNDNo key in a JWK Set matches, too many candidates match, or a lookup function threw or returned nothing.verify, decrypt and the multi variants.
ERR_JWK_UNSUPPORTEDAn algorithm, curve or PEM format the library does not implement.generateKey, generateJWK, wrapKey, unwrapKey, importPEM, exportPEM, deriveJWKFromPassword.
ERR_SESSION_TOKEN_INVALIDAn incoming session token failed verification or decryption.Session reads (to onError), session verify*/unseal*.
ERR_SESSION_READONLY_EVENTupdate() or clear() after the response has been sent.H3 sessions.
ERR_SESSION_KEY_INVALIDThe session key is missing, a password string, the wrong shape, or does not import.Every session function.
ERR_SESSION_CONFIG_INVALIDA session config that cannot work: a bad cookie name, no token source, an unpinned CryptoKey, too many cookie chunks.Every session function.
ERR_INVALID_OPTIONAn option value is malformed or out of range: a bad duration, limits, typ, a non-object header, update data that is not a plain object.Any function that takes options.

#details

Only ERR_JWT_EXPIRED carries structured data. Its cause holds the claims read from the token, so you can log which token expired or tell the user when. isJWTError narrows cause to that shape.

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

const key = await generateJWK("HS256");
const token = await sign({ sub: "u1", jti: "t-1", iat: 1700000000, exp: 1700000600 }, key);

try {
  await verify(token, key);
} catch (error) {
  if (isJWTError(error, "ERR_JWT_EXPIRED")) {
    console.log(error.message);
    // JWT "exp" (Expiration Time) Claim validation failed: Token has expired (exp: 2023-11-14T22:23:20.000Z)
    console.log(error.cause);
    // { jti: "t-1", iat: 1700000000, exp: 1700000600 }
  }
}

A token rejected by maxTokenAge carries the same shape. When a session already loaded on the request runs past its expiry, the error handed to onExpire fills only jti and exp.

#cause

For every other code, cause is typed unknown. It holds the failure underneath when the error wraps one, and is undefined otherwise. The wrapped failures are:

  • A token segment or a JWK field that is not canonical base64url: the cause is an UnsecureError with code MALFORMED, from the codecs unjwt bundles. See unsecure's error codes.
  • A key Web Crypto refused: the platform's DataError or OperationError.
  • A session token that failed to open: the JWTError that verify or decrypt threw.
import { generateJWK } from "unjwt/jwk";
import { sign, verify, isJWTError, isUnsecureError } from "unjwt/jws";

const key = await generateJWK("HS256");
const [header, payload, signature] = (await sign({ sub: "u1" }, key)).split(".");
const tampered = `${header}=.${payload}.${signature}`;

const error = await verify(tampered, key).catch((e: unknown) => e);
if (isJWTError(error, "ERR_JWS_INVALID")) {
  console.log(error.message); // Invalid JWS: Protected header could not be decoded.
  if (isUnsecureError(error.cause, "MALFORMED")) {
    console.log(error.cause.message); // Base64.parse: unexpected "=" padding.
  }
}

Check the cause with isUnsecureError or the UnsecureError exported by unjwt. unjwt bundles its own copy of unsecure, so an UnsecureError imported from your own unsecure dependency is a different class and instanceof against it is always false.

The base64 helpers in unjwt/utils are the one place an UnsecureError is thrown directly, without a JWTError around it.

A session read that fails hands its error to onError instead of throwing. Its cause is the token error:

import { H3 } from "h3";
import { useJWSSession, generateJWK } from "unjwt/adapters/h3v2";
import { isJWTError } from "unjwt/jws";

const key = await generateJWK("HS256");

const app = new H3().get("/", async (event) => {
  const session = await useJWSSession(event, {
    key,
    hooks: {
      onError({ error }) {
        if (isJWTError(error, "ERR_SESSION_TOKEN_INVALID") && isJWTError(error.cause)) {
          console.log(error.message); // [unjwt/h3] Invalid session token: Invalid JWS: Must contain three parts separated by dots.
          console.log(error.cause.code); // ERR_JWS_INVALID
        }
      },
    },
  });
  return { id: session.id ?? null };
});

const request = new Request("http://localhost/", { headers: { cookie: "h3-jws=not-a-token" } });
console.log(await (await app.fetch(request)).json()); // { id: null }

#Branching

Pass the code to isJWTError. It narrows code, and cause too when the code has details. Called with no code, it only checks for a JWTError. Messages name the failing claim, header or option and may change wording between releases; log them, branch on the code.

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

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

async function check(token: string, options?: Parameters<typeof verify>[2]) {
  try {
    await verify(token, key, options);
    return "ok";
  } catch (error) {
    if (isJWTError(error, "ERR_JWT_EXPIRED")) return `expired: ${error.cause.exp}`;
    if (isJWTError(error, "ERR_JWS_SIGNATURE_INVALID")) return "bad signature";
    if (isJWTError(error, "ERR_JWS_ALG_NOT_ALLOWED")) return `refused: ${error.message}`;
    if (isJWTError(error)) return `${error.code}: ${error.message}`;
    throw error;
  }
}

console.log(await check(token)); // ok
console.log(await check(await sign({ sub: "u1" }, otherKey))); // bad signature
console.log(await check(token, { algorithms: ["HS512"] })); // refused: Algorithm not allowed: HS256
console.log(await check("abc.def")); // ERR_JWS_INVALID: Invalid JWS: Must contain three parts separated by dots.
console.log(await check(await sign({ exp: 1700000600 }, key))); // expired: 1700000600

The code argument is a KnownJWTErrorCode, so a typo fails to compile instead of never matching:

if (isJWTError(error, "ERR_JWS_INVALD")) {
}
// error TS2769: No overload matches this call.
//   Argument of type '"ERR_JWS_INVALD"' is not assignable to parameter of type 'KnownJWTErrorCode'.

#What does not throw

These return a value for bad input instead of throwing:

  • The type guards isJWK, isJWKSet, isCryptoKey, isSymmetricJWK, isPrivateJWK, isPublicJWK and the others return false. isJWTContent returns false for a non-string typ or cty.
  • inferJWSAllowedAlgorithms and inferJWEAllowedAlgorithms return undefined when a key says nothing about its algorithm.
  • getJWKsFromSet drops entries that are not JWKs.
  • verifyMultiAll records each signature's failure as { verified: false, error }. It throws only for a broken envelope (ERR_JWS_INVALID_SERIALIZATION) or too many signatures (ERR_JWS_INVALID).
  • A session read (useJWSSession, getJWSSession and their JWE and Elysia counterparts) with a bad or expired token returns an empty session, id undefined, and reports through onError or onExpire. A bad key or config still throws.
  • getJWSSessionToken and getJWESessionToken return undefined when the request carries no token.
  • isJWTError and isUnsecureError return false for anything else, including null.