Migrating to 0.8

Every breaking change between 0.7 and 0.8, each with the code before and after, and the deprecations that go away in 0.9.

Work through the sections that name a function or option you call. Anything not listed here behaves as it did in 0.7.

Note

Most apps need only this.

  1. A string is no longer a key: pass a JWK or a Uint8Array, derive one from a password with deriveJWKFromPassword. See strings.
  2. Sessions sign everyone out once after the upgrade, and exp in session data no longer sets the lifetime. See sessions.
  3. verify and decrypt return a claims object unless you say payload: "string" or "bytes". See payload mode.
  4. wrapKey, unwrapKey and the password derivations take their algorithm in an options object. See options objects.
  5. Five aliases deprecated in 0.6 and 0.7 are gone; fifteen more are deprecated now and go in 0.9. See deprecations.

#Payload mode is declared

In 0.7, verify and decrypt looked at the payload: JSON came back parsed and anything else came back as a string. In 0.8 the caller declares the mode with payload, and the default is "json", a claims object that must be a JSON object.

// 0.7
const { payload } = await verify(token, key, { forceUint8Array: true });
const { payload: text } = await verify(textToken, key); // "hello"

// 0.8
const { payload } = await verify(token, key, { payload: "bytes" });
const { payload: text } = await verify(textToken, key, { payload: "string" }); // "hello"

A text token read with the default mode throws ERR_JWS_INVALID (ERR_JWE_INVALID for decrypt). forceUint8Array still works until 0.9; passing it together with a payload that contradicts it is ERR_INVALID_OPTION. See payload modes.

#A string is a password, never a secret key

0.7 turned a string key into HMAC secret bytes. In 0.8 a string is accepted only as a PBES2 password for JWE. Everywhere else it throws ERR_JWK_INVALID.

// 0.7
await sign(claims, "my-secret", { alg: "HS256" });

// 0.8: a JWK, or a Uint8Array secret
const key = await generateJWK("HS256");
await sign(claims, key);

const secret = crypto.getRandomValues(new Uint8Array(32));
await sign(claims, secret, { alg: "HS256" });
await verify(token, secret, { algorithms: ["HS256"] });

PEM text goes through importPEM. A password that must become a key goes through deriveJWKFromPassword.

#A JWK Set holds public keys

An oct entry in a JWK Set is skipped, so a set fetched from the network cannot carry an HMAC secret. Opt in only for a set your application builds.

// 0.7: oct entries were candidates
await verify(token, { keys: [hmacKey] });

// 0.8
await verify(token, { keys: [hmacKey] }, { allowSymmetricJWKSet: true });

#Generated keys are named by their thumbprint

generateJWK, generateKey({ toJWK: true }) and deriveJWKFromPassword set kid to the key's RFC 7638 thumbprint. 0.7 used crypto.randomUUID(). Both halves of a pair now share one kid, and the name is 43 characters of base64url instead of a UUID. A JWK you stored keeps the kid it has; only keys generated from now on are named this way. Pass your own kid, or kid: false for none.

// 0.7: kid: "6f1c0d2e-…" (random), different on each half
// 0.8: kid: "E6ox2pMUUkbZRTqaq8M3MuSk6NqRvs8la_hezG4vs6Y" (thumbprint), shared by the pair
const { privateKey, publicKey } = await generateJWK("ES256");
const unnamed = await generateJWK("HS256", { kid: false });

See the default kid.

#A key's alg is binding

A JWK's alg names the one thing the key is for. A key with alg: "A256KW" only wraps keys, and asking it for another algorithm is ERR_JWK_INVALID. A key used with alg: "dir" must say dir, or carry the content algorithm (A256GCM, …) it is a key for. Raw Uint8Array keys are never guessed.

// 0.7
await verify(token, secretBytes);
await decrypt(jwe, cekBytes);

// 0.8
await verify(token, secretBytes, { algorithms: ["HS256"] });
await decrypt(jwe, cekBytes, { algorithms: ["dir"], encryptionAlgorithms: ["A256GCM"] });

Without algorithms, a raw key is ERR_JWS_ALG_NOT_ALLOWED or ERR_JWE_ALG_NOT_ALLOWED.

#Options objects and removed aliases

The key-management functions take the subject first and the algorithm inside the options object.

// 0.7
await wrapKey("A256KW", cek, kek);
await unwrapKey("A256KW", encryptedKey, kek, { format: "raw" });
await deriveKeyFromPassword(password, "PBES2-HS256+A128KW", { salt, iterations });
await deriveJWKFromPassword(password, "PBES2-HS256+A128KW", { salt, iterations });

// 0.8
await wrapKey(cek, kek, { alg: "A256KW" });
await unwrapKey(encryptedKey, kek, { alg: "A256KW", format: "raw" });
await deriveKeyFromPassword(password, { alg: "PBES2-HS256+A128KW", salt, iterations });
await deriveJWKFromPassword(password, { alg: "PBES2-HS256+A128KW", salt, iterations });

The aliases deprecated in 0.6 and 0.7 are removed:

RemovedUse
getJWKFromSet(jwks, kid)getJWKsFromSet(jwks, (k) => k.kid === kid)[0]
importFromPEM(pem, pemType, alg, opts)importPEM(pem, alg, { pemType, ...opts })
exportToPEM(jwk, pemFormat, alg)exportPEM(jwk, { pemFormat, alg })
computeExpiresInSeconds, computeMaxTokenAgeSecondscomputeDurationInSeconds

#Sessions (H3 v1, H3 v2, Elysia)

Warning

Every session signs out once after the upgrade: tokens issued by 0.7 carry typ: "JWT" and 0.8 only reads typ: "session+jwt". And a session that set its lifetime through exp in its data has no lifetime in 0.8 until you pass maxAge.

A session key must be key material, a JWK or a CryptoKey. A string throws ERR_SESSION_KEY_INVALID. Derive a key once from your secret and pass the JWK. A JWE key pair needs both halves; 0.7 accepted { privateKey } alone.

// 0.7
useJWESession(event, { key: process.env.SESSION_SECRET });

// 0.8
const key = await deriveJWKFromPassword(process.env.SESSION_SECRET, {
  alg: "PBES2-HS256+A128KW",
  salt: "app-session-salt",
  iterations: 600_000,
});
useJWESession(event, { key });

jti, iat, exp and nbf belong to the adapter. They are dropped from the data passed to update() and from session.data on every issue. In 0.7 an exp in the data became the token's expiry when the session had no maxAge; in 0.8 it is dropped and the token never expires. Set the lifetime on the config, or per token with the second argument.

// 0.7
await session.update({ userId, exp: Math.floor(Date.now() / 1000) + 300 });

// 0.8
await session.update({ userId }, { maxAge: "5m" });
// the token carries { userId, jti, iat, exp }; the exp you put in the data is gone
// 0.7
useJWESession(event, { key: { privateKey } });

// 0.8
useJWESession(event, { key: { privateKey, publicKey } });
// { privateKey } alone: ERR_SESSION_KEY_INVALID: [unjwt/h3] Invalid JWE key: "publicKey" must be a public JWK or a CryptoKey.

unjwt/adapters/h3 now resolves to the h3 v2 adapter (Nuxt 5, Nitro 3). Import unjwt/adapters/h3v1 for h3 v1.

Configs that now throw:

  • A session name ending in a dot and digits (app.1, the spelling of a chunk cookie) is ERR_SESSION_CONFIG_INVALID.
  • A __Host- name with a domain, a path other than / or secure: false, and a __Secure- name with secure: false, are ERR_SESSION_CONFIG_INVALID (RFC 6265bis §4.1.3). 0.7 wrote a cookie browsers drop.
  • update() with anything but a plain object or an updater function is ERR_INVALID_OPTION.
  • Every error is a JWTError with an ERR_SESSION_* code, configuration mistakes included.

Tokens that are now refused, each reaching onError rather than throwing:

  • A token without typ: "session+jwt" (RFC 8725 §3.11). A typ you configure in protectedHeader still wins.
  • A token without exp when the session has a maxAge. The token's own exp decides when it expires.
  • A token issued in the future, an iat later than now plus clockTolerance, with or without a maxAge.

Behaviour that changed without a new error:

  • The cookie is read first, the session header second.
  • A kid set in protectedHeader wins over the key's own kid, as with sign.
  • JWS verifyOptions accept the same core options as JWE decryptOptions (limits, allowSymmetricJWKSet). Options the adapter owns stay out of reach.
  • Clearing a session writes Max-Age=0 and an epoch Expires on every adapter (RFC 6265 §5.2.2).

#Session hooks and rolling

Hooks receive the session manager, not a copy of its state, so a hook can update() or clear() the session it is looking at. onRead fires once per request, when the token is read; later reads in the same request are silent. onUpdate gains trigger, and the new rolling option replaces the hand-written sliding expiry.

// 0.7: reissue from the hook, dedupe repeated reads yourself
hooks: {
  async onRead({ event, session, config }) {
    if (session.expiresAt - Date.now() < 15 * 60_000) await updateJWSSession(event, config);
  },
}

// 0.8
{ key, maxAge: "20m", rolling: "15m" }

See Hooks.

#Stricter on the wire

  • base64url is canonical. A segment with = padding is refused (RFC 7515 §2).

  • crit and b64 must sit in the protected header, and every crit entry must name a parameter that header carries.

  • cty is no longer added to a plain JWT (RFC 7519 §5.2). Set it yourself when you nest tokens.

    // 0.7: sign({ sub }, key) wrote { …, typ: "JWT", cty: "application/json" }
    // 0.8: it writes { kid, alg, typ: "JWT" }; a nested token says so itself
    await sign(innerToken, key, { protectedHeader: { cty: "JWT" } });
  • PBES2 p2c is capped at 600 000 on both sides, so the library never mints a token it would refuse to open. A higher p2c option is ERR_INVALID_OPTION; a token carrying one is ERR_JWE_INVALID.

  • verifyMulti and decryptMulti return in protectedHeader only what was protected. alg and enc may sit in the unprotected tiers (RFC 7515 §7.2.1, RFC 7516 §7.2.1); read them from signerHeader, sharedUnprotectedHeader or recipientHeader. See header tiers.

  • Ed448 and X448 are removed. generateJWK("Ed448") is ERR_JWK_UNSUPPORTED.

  • wrapKey for ECDH-ES behaves like encrypt: a minimal epk, ECDH-ES+A*KW allowed on an ephemeral JWK, and apu/apv at most 1024 bytes, omitted when empty.

  • strictSignerMatch treats kid as a hint for a single key, so a CryptoKey or raw key is still tried. With a JWK Set, the header kid selects the candidates (RFC 7515 §4.1.4).

  • Every verify and decrypt call is bounded: at most 16 signatures, 16 recipients and 8 matching JWK Set candidates, plus a PBES2 iteration budget. 0.7 had no caps. A token or a set beyond them is refused until you raise limits on the call; see limits.

  • isAsymmetricJWK(value) is false for anything that is not a JWK; in 0.7 it was true for any non-oct value, strings included. The guards' typed overloads now require a JWK-typed argument; an unknown goes through the narrowing overload.

Peer ranges: elysia ^1.4.29, and h3 v2 release candidates are no longer accepted (h3 ^1.15.4 || ^2.0.1).

#JOSE plumbing moved to unjwt/internal

Payload parsing, time-claim computation and crit checks moved from unjwt/utils to unjwt/internal, which carries no semver guarantee. The old names stay on unjwt/utils as deprecated aliases until 0.9.

// 0.7
import { decodePayloadFromBytes } from "unjwt/utils";
const claims = decodePayloadFromBytes(bytes);

// 0.8
import { parsePayloadBytes } from "unjwt/internal";
const claims = parsePayloadBytes<MyClaims>(bytes);

#Return types follow the options

Skip this section unless tsc complains. Payload modes and key shapes are typed through maps. A literal option gives the exact type; a variable gives the union. These changes are type-level only:

// A variable typed JWSVerifyOptions now yields JWTClaims | string | Uint8Array
const options: JWSVerifyOptions<"json"> = { audience: "api" }; // keeps JWTClaims

// verify<MyClaims>(token, key, { payload: "string" }) is a type error: drop the type argument
await verify(token, key, { payload: "string" }); // payload: string

// generateKey<A, { toJWK: true }> becomes generateKey<A, true>
const pair = await generateKey<"ES256", true>("ES256", { toJWK: true });
  • importKey(octJwk) is typed Uint8Array, as it always returned.
  • WrapKeyResult.epk and the ECDH ephemeralKey options are EC | OKP; narrow on kty.
  • New exported types: JOSEPayloadTypeMap, PayloadOf, GeneratedKeyMap, GeneratedJWKMap, ImportedKeyMap, ImportedKey.
  • No action: the claims type of verify<T> and decrypt<T> accepts an interface as well as a type, and sign/encrypt take any object. EdDSA and Ed25519 keys sign and verify under either name.

#Deprecations

Deprecated in 0.8.0 and removed in 0.9.0. Each still works in 0.8.

SymbolReplacement
base64UrlEncode (unjwt/utils)base64UrlStringify
base64UrlDecode (unjwt/utils)base64UrlParse
base64Encode (unjwt/utils)base64Stringify
base64Decode (unjwt/utils)base64Parse
forceUint8Array (unjwt/jws, unjwt/jwe)payload: "bytes"
applyTypCtyDefaults (unjwt/utils)applyTypDefault (unjwt/internal)
decodePayloadFromBytes (unjwt/utils)parsePayloadBytes (unjwt/internal)
decodePayloadFromB64UrlSegment (unjwt/utils)parsePayloadSegment (unjwt/internal)
getPlaintextBytes (unjwt/utils)getPlaintextBytes (unjwt/internal)
computeJwtTimeClaims (unjwt/utils)computeJwtTimeClaims (unjwt/internal)
validateCriticalHeadersJWS (unjwt/utils)validateCriticalHeadersJWS (unjwt/internal)
validateCriticalHeadersJWE (unjwt/utils)validateCriticalHeadersJWE (unjwt/internal)
assertCritShape (unjwt/utils)assertCritShape (unjwt/internal)
DecodePayloadOptions (unjwt/utils)ParsePayloadOptions (unjwt/internal)
ComputeJwtTimeClaimsOptions (unjwt/utils)ComputeJwtTimeClaimsOptions (unjwt/internal)