unjwt/internal

The JOSE plumbing that sign, verify, encrypt and decrypt are built from, exposed for tests and tooling.

Warning

unjwt/internal has no semver guarantee. Any export may change or disappear in any release, a patch release included. It exists for tests, fuzzers and tooling that need to exercise one step of the pipeline on its own. Application code uses unjwt/jws, unjwt/jwe and unjwt/utils. The root unjwt entry point does not re-export it.

import { parsePayloadBytes } from "unjwt/internal";

parsePayloadBytes(new TextEncoder().encode('{"sub":"user_42","role":"admin"}'));
// { sub: "user_42", role: "admin" }

#parsePayloadBytes() and parsePayloadSegment()

Turn verified or decrypted payload bytes into the value verify() and decrypt() return. parsePayloadSegment() takes the base64url payload segment instead of bytes and decodes it first.

interface ParsePayloadOptions<Mode extends "json" | "string" | "bytes"> {
  payload?: Mode; // default "json"
  family?: "JWS" | "JWE"; // default "JWS"
}

// Claims overload: name the JSON shape, as verify<MyClaims>() does
function parsePayloadBytes<T = JWTClaims>(
  bytes: Uint8Array<ArrayBuffer>,
  options?: ParsePayloadOptions<"json">,
): T;
// Mode overload: the return type follows `payload`
function parsePayloadBytes<Mode>(
  bytes: Uint8Array<ArrayBuffer>,
  options?: ParsePayloadOptions<Mode>,
): PayloadOf<Mode>;

// parsePayloadSegment has the same two overloads over `segment: string`
OptionTypeDefaultWhat it does
payload"json" | "string" | "bytes""json""json" needs UTF-8 text holding a JSON object. "string" needs valid UTF-8. "bytes" returns the input unchanged.
family"JWS" | "JWE""JWS"Picks the code a rejection carries: ERR_JWS_INVALID or ERR_JWE_INVALID.
CallReturn type
parsePayloadBytes(bytes)JWTClaims
parsePayloadBytes<MyClaims>(bytes)MyClaims
parsePayloadBytes(bytes, { payload: "string" })string
parsePayloadBytes(bytes, { payload: "bytes" })Uint8Array<ArrayBuffer>
parsePayloadBytes(bytes, { payload: mode }) with mode: "json" | "string"PayloadOf<"json" | "string">, which is JWTClaims | string
import { parsePayloadBytes, parsePayloadSegment } from "unjwt/internal";
import { base64UrlStringify, textEncoder } from "unjwt/utils";

const bytes = textEncoder.encode('{"sub":"user_42","role":"admin"}');
parsePayloadBytes(bytes, { payload: "string" }); // '{"sub":"user_42","role":"admin"}'
parsePayloadBytes(bytes, { payload: "bytes" }); // Uint8Array(32) [123, 34, 115, …]

parsePayloadBytes(textEncoder.encode("[1,2]"), { family: "JWE" });
// JWTError ERR_JWE_INVALID: Invalid JWE: Payload is not a JSON object.
parsePayloadBytes(new Uint8Array([0xff, 0xfe]), { payload: "string" });
// JWTError ERR_JWS_INVALID: Invalid JWS: Payload is not valid UTF-8.

parsePayloadBytes(textEncoder.encode('{"a":1,"__proto__":{"x":1}}')); // { a: 1 }

const segment = base64UrlStringify('{"sub":"user_42"}'); // "eyJzdWIiOiJ1c2VyXzQyIn0"
parsePayloadSegment(segment); // { sub: "user_42" }
parsePayloadSegment(segment + "=");
// UnsecureError MALFORMED: Base64.parse: unexpected "=" padding.

JSON is parsed with safeJsonParse, so __proto__, prototype and constructor keys are dropped. A JSON array, string or number is refused: a JWT payload is an object (RFC 7519 §3). parsePayloadSegment throws the decoder's UnsecureError as is when the segment is not canonical base64url; family applies only to the parse step. parsePayloadText(bytes, { family }) is the UTF-8 step on its own.

#applyTypDefault()

applyTypDefault(header, payload) sets typ: "JWT" on a header that has no typ when the payload is an object. It mutates the header and returns it. It never adds cty.

import { applyTypDefault } from "unjwt/internal";

applyTypDefault({ alg: "HS256" }, { sub: "1" }); // { alg: "HS256", typ: "JWT" }
applyTypDefault({ alg: "HS256" }, "hello"); // { alg: "HS256" }
applyTypDefault({ alg: "HS256", typ: "at+jwt" }, { sub: "1" }); // { alg: "HS256", typ: "at+jwt" }
applyTypDefault({ alg: "HS256", typ: 1 }, { sub: "1" });
// JWTError ERR_INVALID_OPTION: "typ" must be a string.

#getPlaintextBytes()

getPlaintextBytes(payload) turns what sign() and encrypt() accept into the bytes they protect: a string is UTF-8 encoded, an object is JSON.stringify'd, and a Uint8Array comes back as the same instance.

import { getPlaintextBytes } from "unjwt/internal";

getPlaintextBytes("hi"); // Uint8Array(2) [104, 105]
getPlaintextBytes({ sub: "1" }); // Uint8Array(11) [123, 34, 115, …]
getPlaintextBytes({ n: 1n });
// JWTError ERR_INVALID_OPTION: Payload is not JSON-serializable.
getPlaintextBytes(42);
// JWTError ERR_INVALID_OPTION: Plaintext must be a string, Uint8Array, or a JSON-serializable object.

#computeJwtTimeClaims()

computeJwtTimeClaims(payload, options) returns a copy of an object payload with iat and the requested exp and nbf added. It returns undefined when there is nothing to add: no time option, a payload that is not an object, or a payload that already holds every requested claim. A claim already in the payload is kept, and an existing iat is the base for expiresIn and notBeforeIn.

OptionTypeWhat it does
currentDateDateThe moment iat is taken from. Defaults to new Date().
expiresIn / expiresAtDuration / Dateexp as iat + expiresIn, or the given instant. Pass one.
notBeforeIn / notBeforeAtDuration / Datenbf as iat + notBeforeIn (0 allowed), or the given instant. Pass one.
import { computeJwtTimeClaims } from "unjwt/internal";

const currentDate = new Date("2026-10-15T12:00:00Z");
computeJwtTimeClaims({ sub: "1" }, { currentDate, expiresIn: "15m" });
// { sub: "1", iat: 1792065600, exp: 1792066500 }
computeJwtTimeClaims({ sub: "1" }, { currentDate, expiresIn: "1h", notBeforeIn: 0 });
// { sub: "1", iat: 1792065600, exp: 1792069200, nbf: 1792065600 }
computeJwtTimeClaims({ sub: "1", exp: 123 }, { currentDate, expiresIn: "1h" }); // undefined
computeJwtTimeClaims({}, { expiresIn: "1h", expiresAt: new Date() });
// JWTError ERR_JWT_CLAIM_INVALID: "expiresIn" and "expiresAt" are mutually exclusive; pass only one.

A bad Duration or an invalid Date is ERR_INVALID_OPTION. A negative notBeforeIn is ERR_JWT_CLAIM_INVALID.

#validateCriticalHeadersJWS() and validateCriticalHeadersJWE()

crit lists header parameters a recipient must understand or reject the token (RFC 7515 §4.1.11, RFC 7516 §4.1.13). These functions check a protected header's crit against the names you pass as recognizedHeaders. JWS also understands b64 (RFC 7797) without being told; JWE understands nothing by default.

import { validateCriticalHeadersJWE, validateCriticalHeadersJWS } from "unjwt/internal";

const header = { "alg": "HS256", "urn:example:tenant": "acme", "crit": ["urn:example:tenant"] };
validateCriticalHeadersJWS(header, ["urn:example:tenant"]); // passes
validateCriticalHeadersJWS(header);
// JWTError ERR_JWS_INVALID: Unprocessed critical header parameters: urn:example:tenant
validateCriticalHeadersJWS({ alg: "HS256", b64: false, crit: ["b64"] }); // passes
validateCriticalHeadersJWE({ alg: "dir", crit: ["exp"] }, ["exp"]);
// JWTError ERR_JWE_INVALID: Critical header parameter "exp" listed in "crit" but not present in the protected header.

#assertCritShape()

assertCritShape(crit, { family, code? }) checks the shape alone: a non-empty array of strings that names no registered header parameter (alg, kid, typ, …). code overrides the default ERR_<family>_INVALID.

import { assertCritShape } from "unjwt/internal";

assertCritShape(["urn:example:tenant"], { family: "JWS" }); // passes
assertCritShape([], { family: "JWS" });
// JWTError ERR_JWS_INVALID: Invalid JWS: "crit" must be a non-empty array of strings.
assertCritShape(["kid"], { family: "JWS", code: "ERR_JWS_INVALID_SERIALIZATION" });
// JWTError ERR_JWS_INVALID_SERIALIZATION: Invalid JWS: "crit" must not list registered header parameters.

expiredClaimMessage(exp) is exported too. It builds the ERR_JWT_EXPIRED message the session adapters reuse.