native-jwe
v2.0.0
Published
Zero-dependency RFC 7516/7515 compact JWE (RSA-OAEP-256 + A256GCM) and JWS (RS256) implementation for Node.js
Maintainers
Readme
native-jwe
A zero-runtime-dependency implementation of:
- JWE (RFC 7516, Compact Serialization), fixed to
RSA-OAEP-256(key management) +A256GCM(content encryption). - JWS (RFC 7515, Compact Serialization), fixed to
RS256.
for Node.js >= 18, built only on node:crypto.
This library deliberately supports exactly one algorithm pair for JWE and exactly one algorithm for JWS. It is not a general-purpose JOSE library, does not negotiate algorithms, and does not read alg off an incoming token to decide what crypto primitive to call — every primitive name is a hardcoded string literal in src/jwe.ts / src/jws.ts. See Limitations for what is explicitly out of scope.
If you are upgrading from v1.x, read MIGRATION-v1-to-v2.md first — v1 tokens cannot be read by v2.
Install
npm install native-jweQuickstart
const { generateKeyPairSync } = require('node:crypto');
const { JWE, JWS } = require('native-jwe');
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
// --- JWE: encrypt / decrypt ---
const claims = JSON.stringify({ sub: 'alice', scope: ['read'] });
const jwe = JWE.encrypt(claims, publicKey);
const decrypted = JWE.decrypt(jwe, privateKey);
// decrypted === claims
// --- JWS: sign / verify ---
const jws = JWS.encode(claims, privateKey);
const isValid = JWS.verify(jws, publicKey); // boolean
const payload = JWS.decodeUnverified(jws); // string — see warning belowEvery snippet in this README was executed against the compiled dist/ output before being committed; none of them are hypothetical.
API reference
The public surface is exactly what src/index.ts exports: JWE, JWS, MEDIA_TYPES, the error classes, and their TypeScript types. There is no other entry point.
JWE.encrypt(value, publicKey, options?) => string
Encrypts value (a string) into a JWE Compact Serialization for publicKey (a PEM string, DER Buffer, or KeyObject — anything node:crypto accepts as KeyLike).
options:
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| cty | MEDIA_TYPES.JSON | MEDIA_TYPES.JSON | Optional. Only "json" is accepted if supplied. |
Throws JweError (code: 'ERR_JWE_UNSUPPORTED_OPTION') if cty is set to anything other than the one value it currently supports.
JWE.decrypt(payload, privateKey, passphrase?) => string
Decrypts a JWE Compact Serialization produced by JWE.encrypt — or by any other spec-compliant RSA-OAEP-256/A256GCM producer (verified by interop test, see below).
passphrase— optional; only needed ifprivateKeyis an encrypted PEM.- A protected header carrying a
zipparameter, of any value, is rejected outright (RFC 7516 §4.1.13 principle applied tozip; see Limitations).
Every failure mode throws the same JweError. See Error handling below — this is intentional, not an oversight.
JWS.encode(value, privateKey, passphrase?) => string
Signs value (a string) with RS256, returning a JWS Compact Serialization. passphrase is optional, for an encrypted PEM private key.
JWS.verify(jwsPayload, publicKey) => boolean
Verifies a JWS Compact Serialization against publicKey. Returns true or false — it never throws. Every failure mode (malformed input, wrong segment count, non-canonical base64url, an alg other than RS256, a crit header, a bad signature) returns false.
JWS.decodeUnverified(jwsPayload) => string
Warning — this function does NOT verify the signature. It extracts and base64url-decodes the payload segment only. Do not trust the result unless
JWS.verifyhas already returnedtruefor the samejwsPayload, or you have some other reason to deliberately skip verification. This was nameddecodein v1.0.0, which gave no indication that the result was unverified; it is nowdecodeUnverifiedfor exactly that reason.
Throws JwsError (code: 'ERR_JWS_INVALID_INPUT') if the input is not a string, is empty/oversized, or does not have exactly 3 non-empty segments.
MEDIA_TYPES
enum MEDIA_TYPES { JSON = 'json' }The only value this library currently understands for the cty header parameter.
Error handling
All errors are typed classes (src/errors.ts), never a bare Error:
| Class | Thrown by | code | Notes |
| --- | --- | --- | --- |
| JweError | JWE.encrypt | ERR_JWE_UNSUPPORTED_OPTION | Distinguishable — this is a caller programming error (bad options), not a token being decrypted. |
| JweError | JWE.decrypt | ERR_JWE_DECRYPT_FAILED | The only error JWE.decrypt ever throws. See below. |
| JwsError | JWS.decodeUnverified | ERR_JWS_INVALID_INPUT | Malformed/empty/oversized input. |
JWS.verify never throws; it returns boolean (see API reference above).
JWE.decrypt failures are deliberately indistinguishable
A bad alg, a malformed base64url segment, a truncated auth tag, a wrong-length IV, a CEK that RSA-decrypts to the wrong length, a crit header, a failed GCM authentication tag check, and a wrong private key all produce the exact same error: the same message ("JWE decryption failed"), the same code (ERR_JWE_DECRYPT_FAILED), and no .cause.
This is intentional, not a debugging shortcoming. RFC 7516's security considerations (via the RSA padding-oracle countermeasures in RFC 3218 that they reference) require it: if an attacker can distinguish why a decryption failed, that difference itself becomes a decryption oracle they can use to recover key material or plaintext one bit at a time. JWE.decrypt never logs, and never attaches the real underlying error as .cause, for the same reason — see test/tamper.test.js, test "JWE: every distinct decrypt failure mode produces an identical message and code (decryption-oracle guard)".
The library also never writes to stdout/stderr on any failure path (test/tamper.test.js, test "library writes nothing to stdout or stderr on a failed decrypt or a failed verify"). If you need to detect and monitor decryption failures, catch the typed error at your application layer and log it there — with whatever context (request ID, caller IP, etc.) makes sense for your system, not the library's:
const { generateKeyPairSync } = require('node:crypto');
const { JWE, JweError } = require('native-jwe');
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
// A mismatched key pair, purely so this example actually exercises the
// catch branch below — in real code `token` would come over the wire.
const { privateKey: wrongPrivateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const token = JWE.encrypt(JSON.stringify({ sub: 'alice' }), publicKey);
try {
const claims = JWE.decrypt(token, wrongPrivateKey);
console.log(claims);
} catch (err) {
if (err instanceof JweError) {
// Log at the application layer, with whatever context (request ID,
// caller IP, etc.) makes sense for *your* system — the error itself
// intentionally carries no information about which check failed.
console.warn({ code: err.code, requestId: 'req-123' }, 'JWE decrypt failed');
}
}Security properties
Only properties that are enforced in code and covered by a test are listed here, each cited to its test file:
| Property | Enforced in | Proven by |
| --- | --- | --- |
| OAEP key-wrap uses SHA-256 (not Node's SHA-1 default) for RSA-OAEP-256 | src/jwe.ts (oaepHash: 'sha256') | test/tamper.test.js ("the encrypted CEK is bound to SHA-256 OAEP") |
| AAD is the exact base64url protected-header bytes as received on the wire, never a re-serialization (RFC 7516 §5.1) | src/jwe.ts | test/tamper.test.js ("re-serializing the protected header breaks AAD binding") |
| IV is exactly 12 bytes (RFC 7518 §5.3); any other length rejected | src/jwe.ts | test/jwe.test.js, test/tamper.test.js ("emitted IV is exactly 12 bytes...") |
| Auth tag is exactly 16 bytes; truncated tags rejected before setAuthTag | src/jwe.ts | test/tamper.test.js ("a truncated auth tag is rejected") |
| Decrypted CEK must be exactly 32 bytes, checked before createDecipheriv is ever called | src/jwe.ts | test/tamper.test.js ("rejected before createDecipheriv is ever called") |
| JWE requires exactly 5 non-empty segments, JWS exactly 3; extra/missing segments rejected | src/jwe.ts, src/jws.ts | test/tamper.test.js ("rejects 4-segment and 6-segment tokens", "rejects 2-segment and 4-segment tokens") |
| base64url segments must be in canonical form (no stray padding, no standard-base64 +//) | src/base64url.ts | test/base64url.test.js, test/tamper.test.js ("every segment rejects added padding...") |
| A crit header of any content is rejected outright (RFC 7516 §4.1.13 / RFC 7515 §4.1.11) | src/jwe.ts, src/jws.ts | test/tamper.test.js ("header containing crit is rejected") |
| A zip header parameter of any value is rejected outright — zip is not implemented (see Limitations) | src/jwe.ts | test/tamper.test.js ("header containing zip (any value) is rejected"), test/interop.test.js ("a jose-produced JWE carrying zip: DEF is rejected") |
| Size limits are measured in UTF-8 bytes, not UTF-16 code units | src/jwe.ts, src/jws.ts | test/tamper.test.js ("oversized-input check is byte-based") |
| All JWE.decrypt failure modes are observationally identical (no decryption oracle); nothing is written to stdout/stderr on failure | src/errors.ts, src/jwe.ts | test/tamper.test.js ("every distinct decrypt failure mode produces an identical message and code", "library writes nothing to stdout or stderr...") |
| RSA-OAEP-256 / A256GCM JWEs and RS256 JWSs interoperate with a spec-correct third-party implementation in both directions | src/jwe.ts, src/jws.ts | test/interop.test.js, cross-checked against jose (devDependency, test-only — never a runtime dependency) |
Two claims from the v1.0.0 README were removed rather than softened, because the code does not support them:
- No timing-attack claim. There is no
crypto.timingSafeEqualand no constant-time comparison anywhere insrc/. Header/option string comparisons (===) are ordinary, not constant-time. If you need constant-time comparison semantics for your own header handling on top of this library, you must add it yourself. - No performance claim. v1.0.0 claimed speed "comparable to compiled languages like Go/Rust." That claim shipped with zero benchmarks and is not repeated here. "Zero runtime dependencies" and "uses Node's native OpenSSL bindings via
node:crypto" are the only performance-adjacent statements this README makes, and both are verifiable frompackage.json(no runtimedependencies) andsrc/jwe.ts/src/jws.ts(calls tocreateCipheriv,publicEncrypt,createSign, etc.).
Limitations
This library deliberately does not implement:
kid/ keystore / key rotation. There is no concept of a key identifier or a key store. Callers are responsible for knowing which key pair to use.- Claims validation.
exp,nbf,iss,aud, or any other registered claim are never inspected.JWE.decryptandJWS.decodeUnverifiedreturn the raw payload string you gave toencrypt/encode; validating its contents (including expiry) is entirely the calling application's job. - JWK support. Keys are
KeyLike(PEM string, DERBuffer, orKeyObject) only; there is no JWK parsing or serialization. - Algorithm agility. Exactly one algorithm pair for JWE (
RSA-OAEP-256+A256GCM) and one for JWS (RS256). Noalg/encnegotiation, noA128GCM, noECDH-ES. - JSON Serialization. Compact Serialization only — no JWE/JWS JSON Serialization, no multi-recipient tokens.
- Constant-time header comparison. Header and option field comparisons use ordinary
===, notcrypto.timingSafeEqual. - npm provenance attestation. Not yet configured for this package.
- Compression (
zip) is deliberately not supported. Compressing the plaintext before AEAD encryption creates a compression-oracle side channel (the CRIME/BREACH class of attack): because AES-GCM preserves plaintext length, the length of the compressed-then-encrypted output leaks information about the plaintext whenever compressible attacker-controlled data is mixed with a secret in the same payload. An earlier revision of this library implementedzipand an internal adversarial review recovered a full secret value from token lengths alone under exactly that condition.JWE.decryptrejects any protected header carrying azipparameter, of any value, rather than attempting to process it.
If your application needs any of the above, it must be built on top of this library, not inside it.
Credits & acknowledgments
This project is inspired by and based on portions of the work from psenger/jwe_example (MIT License, Copyright (c) 2019 Philip A Senger).
License
MIT — see LICENSE.
