
# 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.
::

```ts
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.

```ts
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` |

```ts
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](https://www.rfc-editor.org/rfc/rfc7519#section-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`.

```ts
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.

```ts
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. |

```ts
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](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.11), [RFC 7516 §4.1.13](https://www.rfc-editor.org/rfc/rfc7516#section-4.1.13)). These functions check a protected header's `crit` against the names you pass as `recognizedHeaders`. JWS also understands `b64` ([RFC 7797](https://www.rfc-editor.org/rfc/rfc7797)) without being told; JWE understands nothing by default.

```ts
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`.

```ts
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.
