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
| Code | Meaning | Raised by |
|---|---|---|
ERR_JWT_EXPIRED | exp 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_NBF | nbf is in the future. | The same claim checks. |
ERR_JWT_CLAIM_INVALID | iss, 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_MISSING | A claim the options require is absent. | Claim checks. |
ERR_JWS_INVALID | The 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_SERIALIZATION | A JSON JWS that is neither General nor Flattened. | verifyMulti, verifyMultiAll, generalToFlattenedJWS. |
ERR_JWS_SIGNATURE_INVALID | The signature does not verify under the key. | verify, verifyMulti. |
ERR_JWS_ALG_NOT_ALLOWED | alg is none, unknown, outside algorithms, or no allowlist can be inferred from the key. | verify, verifyMulti, sign, signMulti. |
ERR_JWS_ALG_MISSING | sign cannot tell which algorithm to use. | sign. |
ERR_JWS_SIGNER_ALG_INFERENCE | A signer's key carries no usable alg. | signMulti. |
ERR_JWS_NO_MATCHING_SIGNER | strictSignerMatch found no signature the key could have produced. | verifyMulti. |
ERR_JWS_HEADER_PARAMS_NOT_DISJOINT | A name sits in both the protected and the unprotected header of a signature. | signMulti, verifyMulti. |
ERR_JWS_B64_INCONSISTENT | Signers disagree on b64 (RFC 7797 §3). | signMulti. |
ERR_JWE_INVALID | The 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_SERIALIZATION | A JSON JWE that is neither General nor Flattened. | decryptMulti, encryptMulti, generalToFlattened. |
ERR_JWE_DECRYPTION_FAILED | Key unwrap or content decryption failed. One code for every cause (RFC 7516 §11.5). | decrypt, decryptMulti, unwrapKey. |
ERR_JWE_ALG_NOT_ALLOWED | alg or enc is none, unknown, or outside algorithms / encryptionAlgorithms. | decrypt, decryptMulti, encrypt, encryptMulti. |
ERR_JWE_ALG_MISSING | encrypt cannot tell which key management algorithm to use. | encrypt. |
ERR_JWE_ENC_MISSING | alg: "dir" without an enc. | encrypt. |
ERR_JWE_ALG_FORBIDDEN_IN_MULTI | dir or bare ECDH-ES given to a multi-recipient message. | encryptMulti. |
ERR_JWE_RECIPIENT_ALG_INFERENCE | A recipient key is not a JWK or carries no alg. | encryptMulti. |
ERR_JWE_NO_MATCHING_RECIPIENT | strictRecipientMatch found no recipient the key could open. | decryptMulti. |
ERR_JWE_HEADER_PARAMS_NOT_DISJOINT | A name sits in more than one header tier. | encryptMulti, decryptMulti. |
ERR_JWK_INVALID | Key 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_FOUND | No 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_UNSUPPORTED | An algorithm, curve or PEM format the library does not implement. | generateKey, generateJWK, wrapKey, unwrapKey, importPEM, exportPEM, deriveJWKFromPassword. |
ERR_SESSION_TOKEN_INVALID | An incoming session token failed verification or decryption. | Session reads (to onError), session verify*/unseal*. |
ERR_SESSION_READONLY_EVENT | update() or clear() after the response has been sent. | H3 sessions. |
ERR_SESSION_KEY_INVALID | The session key is missing, a password string, the wrong shape, or does not import. | Every session function. |
ERR_SESSION_CONFIG_INVALID | A session config that cannot work: a bad cookie name, no token source, an unpinned CryptoKey, too many cookie chunks. | Every session function. |
ERR_INVALID_OPTION | An 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
causeis anUnsecureErrorwith codeMALFORMED, from the codecs unjwt bundles. See unsecure's error codes. - A key Web Crypto refused: the platform's
DataErrororOperationError. - A session token that failed to open: the
JWTErrorthatverifyordecryptthrew.
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: 1700000600The 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,isPublicJWKand the others returnfalse.isJWTContentreturnsfalsefor a non-stringtyporcty. inferJWSAllowedAlgorithmsandinferJWEAllowedAlgorithmsreturnundefinedwhen a key says nothing about its algorithm.getJWKsFromSetdrops entries that are not JWKs.verifyMultiAllrecords 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,getJWSSessionand their JWE and Elysia counterparts) with a bad or expired token returns an empty session,idundefined, and reports throughonErrororonExpire. A bad key or config still throws. getJWSSessionTokenandgetJWESessionTokenreturnundefinedwhen the request carries no token.isJWTErrorandisUnsecureErrorreturnfalsefor anything else, includingnull.