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.
- A string is no longer a key: pass a JWK or a
Uint8Array, derive one from a password withderiveJWKFromPassword. See strings. - Sessions sign everyone out once after the upgrade, and
expin session data no longer sets the lifetime. See sessions. verifyanddecryptreturn a claims object unless you saypayload: "string"or"bytes". See payload mode.wrapKey,unwrapKeyand the password derivations take their algorithm in an options object. See options objects.- 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:
| Removed | Use |
|---|---|
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, computeMaxTokenAgeSeconds | computeDurationInSeconds |
#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) isERR_SESSION_CONFIG_INVALID. - A
__Host-name with adomain, apathother than/orsecure: false, and a__Secure-name withsecure: false, areERR_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 isERR_INVALID_OPTION.- Every error is a
JWTErrorwith anERR_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). Atypyou configure inprotectedHeaderstill wins. - A token without
expwhen the session has amaxAge. The token's ownexpdecides when it expires. - A token issued in the future, an
iatlater than now plusclockTolerance, with or without amaxAge.
Behaviour that changed without a new error:
- The cookie is read first, the session header second.
- A
kidset inprotectedHeaderwins over the key's ownkid, as withsign. - JWS
verifyOptionsaccept the same core options as JWEdecryptOptions(limits,allowSymmetricJWKSet). Options the adapter owns stay out of reach. - Clearing a session writes
Max-Age=0and an epochExpireson 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).critandb64must sit in the protected header, and everycritentry must name a parameter that header carries.ctyis 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
p2cis capped at 600 000 on both sides, so the library never mints a token it would refuse to open. A higherp2coption isERR_INVALID_OPTION; a token carrying one isERR_JWE_INVALID.verifyMultianddecryptMultireturn inprotectedHeaderonly what was protected.algandencmay sit in the unprotected tiers (RFC 7515 §7.2.1, RFC 7516 §7.2.1); read them fromsignerHeader,sharedUnprotectedHeaderorrecipientHeader. See header tiers.Ed448 and X448 are removed.
generateJWK("Ed448")isERR_JWK_UNSUPPORTED.wrapKeyfor ECDH-ES behaves likeencrypt: a minimalepk,ECDH-ES+A*KWallowed on an ephemeral JWK, andapu/apvat most 1024 bytes, omitted when empty.strictSignerMatchtreatskidas a hint for a single key, so aCryptoKeyor raw key is still tried. With a JWK Set, the headerkidselects the candidates (RFC 7515 §4.1.4).Every
verifyanddecryptcall 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 raiselimitson the call; see limits.isAsymmetricJWK(value)isfalsefor anything that is not a JWK; in 0.7 it wastruefor any non-octvalue, strings included. The guards' typed overloads now require aJWK-typed argument; anunknowngoes 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 typedUint8Array, as it always returned.WrapKeyResult.epkand the ECDHephemeralKeyoptions areEC | OKP; narrow onkty.- New exported types:
JOSEPayloadTypeMap,PayloadOf,GeneratedKeyMap,GeneratedJWKMap,ImportedKeyMap,ImportedKey. - No action: the claims type of
verify<T>anddecrypt<T>accepts aninterfaceas well as atype, andsign/encrypttake any object.EdDSAandEd25519keys sign and verify under either name.
#Deprecations
Deprecated in 0.8.0 and removed in 0.9.0. Each still works in 0.8.
| Symbol | Replacement |
|---|---|
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) |