JWE
JWE — encrypted tokens
A JSON Web Encryption (RFC 7516) is a JWT whose payload is encrypted: no one can read it without the right key, and no one can tamper with it without the tag failing.
Import:
import { encrypt, decrypt } from "unjwt/jwe";
// or from the flat barrel:
import { encrypt, decrypt } from "unjwt";
Basic usage
The shortest path — password-based encryption (PBES2 under the hood):
import { encrypt, decrypt } from "unjwt/jwe";
const token = await encrypt({ secret: "sensitive" }, "my-password");
const { payload } = await decrypt(token, "my-password");
console.log(payload);
// { secret: "sensitive" }
The token looks like a JWT (dotted segments) but the third segment onwards is ciphertext — base64url-decoding the payload gives you random bytes, not claims.
How a JWE is built
Every JWE encrypts its payload in two steps:
Content encryption — a random Content Encryption Key (CEK) encrypts the payload with an authenticated cipher (AES-GCM or AES-CBC+HMAC-SHA2). The result is the ciphertext segment plus an IV and authentication tag.
Key management — the CEK itself is delivered to the recipient, either wrapped by a key they hold, or derived from a shared secret with them. That's what the alg header describes.
So every JWE header has two algorithm fields:
alg— how the CEK is delivered. Determines what kind of recipient key is required.enc— how the CEK encrypts the payload. Determines the content cipher.
{ "alg": "PBES2-HS256+A128KW", "enc": "A128GCM", "p2s": "...", "p2c": 600000 }
You'll see these in every example below. Full list in Algorithms.
Choosing the alg family
| You have… | Use alg… |
|---|---|
| A password string | PBES2-* (inferred automatically for passwords) |
| A shared secret key (both sides hold it) | A128KW/A256KW (Key Wrap) or dir (direct) |
| The recipient's RSA public key | RSA-OAEP-256 / RSA-OAEP-512 |
| The recipient's EC public key | ECDH-ES or ECDH-ES+A256KW |
| A prearranged CEK used directly as the key | dir |
Algorithm inference handles most of this for you — passing a JWK with a alg field set picks the right path. See Encrypting →.
The three encryption patterns
The rest of JWE is just variations on three recurring shapes:
1. Password-based
One secret known to both parties. Simplest, slowest (by design).
const token = await encrypt({ data: "x" }, "my-password");
const { payload } = await decrypt(token, "my-password");
2. Shared key (symmetric)
A key that both parties already hold — you generated and distributed it out of band.
const key = await generateJWK("A256KW");
const token = await encrypt({ data: "x" }, key);
const { payload } = await decrypt(token, key);
3. Public-key (asymmetric)
The sender only needs the recipient's public key; only the holder of the matching private key can decrypt. Essential for end-to-end encryption.
const { publicKey, privateKey } = await generateJWK("RSA-OAEP-256");
// ... publicKey distributed out-of-band ...
const token = await encrypt({ data: "x" }, publicKey);
const { payload } = await decrypt(token, privateKey);
Elliptic-curve equivalent:
const { publicKey, privateKey } = await generateJWK("ECDH-ES+A256KW");
const token = await encrypt({ data: "x" }, publicKey);
const { payload } = await decrypt(token, privateKey);
See ECDH-ES → for the full end-to-end messaging story.
Going further
- Encrypting in depth → — every
encrypt()option. - Decrypting in depth → — allowlists, PBES2 DoS bounds, key lookup.
- Multi-recipient → — one ciphertext, many recipients (JSON Serialization).
- ECDH-ES and end-to-end encryption → — the public-key workflow in detail.
- Algorithms → — picking
algandenc.