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`| Option | Type | Default | What 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. |
| Call | Return 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.
| Option | Type | What it does |
|---|---|---|
currentDate | Date | The moment iat is taken from. Defaults to new Date(). |
expiresIn / expiresAt | Duration / Date | exp as iat + expiresIn, or the given instant. Pass one. |
notBeforeIn / notBeforeAt | Duration / Date | nbf 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.