
# 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`](/jwk/password). See [strings](#a-string-is-a-password-never-a-secret-key).
2. Sessions sign everyone out once after the upgrade, and `exp` in session data no longer sets the lifetime. See [sessions](#sessions-h3-v1-h3-v2-elysia).
3. `verify` and `decrypt` return a claims object unless you say `payload: "string"` or `"bytes"`. See [payload mode](#payload-mode-is-declared).
4. `wrapKey`, `unwrapKey` and the password derivations take their algorithm in an options object. See [options objects](#options-objects-and-removed-aliases).
5. Five aliases deprecated in 0.6 and 0.7 are gone; fifteen more are deprecated now and go in 0.9. See [deprecations](#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.

```ts
// 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](/getting-started/concepts#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`.

```ts
// 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`](/jwk/pem). A password that must become a key goes through [`deriveJWKFromPassword`](/jwk/password).

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

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

```ts
// 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](/jwk/generate#the-default-kid-is-a-thumbprint).

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

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

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

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

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

```ts
// 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](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-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](https://www.rfc-editor.org/rfc/rfc8725#section-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](https://www.rfc-editor.org/rfc/rfc6265#section-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.

```ts
// 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](/adapters/hooks).

## Stricter on the wire

- base64url is canonical. A segment with `=` padding is refused ([RFC 7515 §2](https://www.rfc-editor.org/rfc/rfc7515#section-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](https://www.rfc-editor.org/rfc/rfc7519#section-5.2)). Set it yourself when you nest tokens.

  ```ts
  // 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](https://www.rfc-editor.org/rfc/rfc7515#section-7.2.1), [RFC 7516 §7.2.1](https://www.rfc-editor.org/rfc/rfc7516#section-7.2.1)); read them from `signerHeader`, `sharedUnprotectedHeader` or `recipientHeader`. See [header tiers](/getting-started/concepts#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](https://www.rfc-editor.org/rfc/rfc7515#section-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](/jwt/jws/verify#options).
- `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`](/utils/internal), which carries no semver guarantee. The old names stay on `unjwt/utils` as deprecated aliases until 0.9.

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

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

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