Skip to content
ecdsa.com
Node.js · WebCrypto

jose (panva)

Filip Skokan's jose: a dependency-free JOSE implementation (JWS, JWE, JWK, JWKS, JWT) on top of WebCrypto that runs unchanged in Node.js, browsers, Deno, Bun and edge runtimes, with EdDSA and ML-DSA but no secp256k1.
License
MIT
Checked version
6.2.13 ·
Source
github.com/panva/jose
Documentation
github.com/panva/jose/tree/main/docs

Compatibility at a glance

JOSE and JWT

JWS algorithms
HS256HS384HS512RS256RS384RS512PS256PS384PS512ES256ES384ES512EdDSAEd25519ML-DSA-44ML-DSA-65ML-DSA-87
Public key formats
spki-pemx509jwk

jose covers the whole JOSE family — signed and encrypted tokens, JWK import and export, local and remote JWK sets — with one API in every JavaScript runtime. Since version 6 it does all cryptography through WebCrypto, so keys are CryptoKey objects (Node.js KeyObject instances are still accepted), and the library inherits WebCrypto's curve list: ES256, ES384 and ES512, EdDSA and Ed25519, and in 6.2.13 the ML-DSA algorithms where the runtime provides them. ES256K and Ed448 are refused with ERR_JOSE_NOT_SUPPORTED.

Its defaults are strict where it matters. jwtVerify() never accepts alg: none — unsigned tokens can only be read through the separate UnsecuredJWT class — and a key is bound to one algorithm family, so an HS256 token presented with an EC public key, or an RS256 token with an EC key, fails with a TypeError instead of verifying. ES256 also refuses a P-384 key. Pass algorithms anyway: with a key set that holds several key types, it is what stops an issuer from switching algorithms silently.

Two defaults cut both ways. clockTolerance is zero, which is strict, and exp is optional, which is not: a token without an expiry verifies unless you list it in requiredClaims, as the sample does. A JWS whose ECDSA signature arrived as DER — typical when a token is assembled by hand with a cloud KMS or openssl dgst — fails with the generic signature verification failed, with nothing pointing at the encoding; the JWT debugger shows the signature length at a glance, and the DER ⇄ raw converter fixes it.

Key import is explicit: importSPKI and importPKCS8 take PEM strings — DER bytes are refused with "spki" must be SPKI formatted string — importX509 takes a PEM certificate, and importJWK takes a JWK. The SEC1 EC PRIVATE KEY files that OpenSSL writes by default are rejected by importPKCS8; convert them to PKCS#8 first, for example with the JWK converter.

For verifying tokens from an identity provider, use createRemoteJWKSet with the provider's jwks_uri: it caches keys, refetches on an unknown kid and rate-limits refetches. Check what the provider actually publishes with the JWKS checker.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/jose/run.sh ran jose (panva) 6.2.13 on Node.js 24.22.0 in the container image node:24.22.0-bookworm-slim on October 20, 2026; the page must match what it printed, and each fact lists those lines.

  • JWS algorithms

    HS256HS384HS512RS256RS384RS512PS256PS384PS512ES256ES384ES512EdDSAEd25519ML-DSA-44ML-DSA-65ML-DSA-87

    HS, RS, PS and ES at 256/384/512 bits, EdDSA and Ed25519, and ML-DSA-44/65/87 (on Node.js 24.22) signed and verified in the check; ES256K and Ed448 raised ERR_JOSE_NOT_SUPPORTED.

    Recorded by the check
    fact jws-algorithms = HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA, Ed25519, ML-DSA-44, ML-DSA-65, ML-DSA-87
    evidence jws-algorithms: ES256K: ERR_JOSE_NOT_SUPPORTED: Invalid or unsupported "alg" (Algorithm) value
    evidence jws-algorithms: Ed448: ERR_JOSE_NOT_SUPPORTED: Invalid or unsupported "alg" (Algorithm) value
  • ES256 signature encoding

    raw

    ES256 signatures in compact tokens are 64 bytes of raw r‖s, the form RFC 7518 requires.

    Recorded by the check
    fact jws-ecdsa-encoding = raw
  • DER signature inside a JWS

    rejected

    A token whose ES256 signature was re-encoded as DER fails with ERR_JWS_SIGNATURE_VERIFICATION_FAILED, the same error as a forged signature.

    Recorded by the check
    fact jws-der-signature = rejected
    evidence jws-der-signature: ERR_JWS_SIGNATURE_VERIFICATION_FAILED: signature verification failed
  • Algorithm allow-list

    from-key

    Without algorithms, any algorithm the key can perform is allowed; an RS256 token against an EC key failed with CryptoKey does not support this operation, its algorithm.name must be RSASSA-PKCS1-v1_5.

    Recorded by the check
    fact jwt-algorithm-allowlist = from-key
    evidence jwt-algorithm-allowlist: RS256 token against an EC key: TypeError: CryptoKey does not support this operation, its algorithm.name must be RSASSA-PKCS1-v1_5
  • Unsigned tokens (alg none)

    rejected

    jwtVerify() refuses alg: none even when none is listed in algorithms; unsecured tokens can only be decoded with UnsecuredJWT.decode().

    Recorded by the check
    fact jwt-alg-none = rejected
    evidence jwt-alg-none: jwtVerify: ERR_JOSE_NOT_SUPPORTED: alg none is not supported either by JOSE or your javascript runtime
    evidence jwt-alg-none: jwtVerify with algorithms ["none"]: ERR_JOSE_NOT_SUPPORTED: alg none is not supported either by JOSE or your javascript runtime
    evidence jwt-alg-none: UnsecuredJWT.decode: returned sub fact-check
  • Public key used as an HMAC secret

    rejected

    An HS256 token keyed with the public key's PEM text fails against that public key with CryptoKey instances must be of type "secret" for the HS256 algorithm, with or without an algorithm list.

    Recorded by the check
    fact jwt-hmac-public-key = rejected
    evidence jwt-hmac-public-key: no list: TypeError: CryptoKey instances must be of type "secret" for the HS256 algorithm
    evidence jwt-hmac-public-key: algorithms ["HS256"]: TypeError: CryptoKey instances must be of type "secret" for the HS256 algorithm
  • Curve checked against alg

    enforced

    Signing ES256 with a P-384 key fails with its algorithm.namedCurve must be P-256.

    Recorded by the check
    fact jwt-ecdsa-curve-check = enforced
    evidence jwt-ecdsa-curve-check: TypeError: CryptoKey does not support this operation, its algorithm.namedCurve must be P-256
  • Default clock skew

    0s

    No leeway by default: a token that expired three seconds earlier was rejected; set clockTolerance to allow for drift.

    Recorded by the check
    fact jwt-clock-skew = 0s
  • Expiry claim required

    no

    A token without exp verified; add requiredClaims: ["exp"] to require it.

    Recorded by the check
    fact jwt-exp-required = no
  • Public key formats

    spki-pemx509jwk

    importSPKI (PEM string), importX509 (PEM certificate) and importJWK worked; DER bytes passed to importSPKI were refused. Node.js KeyObject instances were also accepted directly.

    Recorded by the check
    fact public-key-formats = spki-pem, x509, jwk
    evidence public-key-formats: spki-der: TypeError: "spki" must be SPKI formatted string
    evidence public-key-formats: KeyObject from node:crypto accepted as a key: true
  • Private key formats

    pkcs8-pemjwk

    importPKCS8 takes a PKCS#8 PEM string and importJWK a JWK with d; a SEC1 EC PRIVATE KEY PEM was refused with "pkcs8" must be PKCS#8 formatted string.

    Recorded by the check
    fact private-key-formats = pkcs8-pem, jwk
    evidence private-key-formats: sec1-pem: TypeError: "pkcs8" must be PKCS#8 formatted string

Usage

Issue an ES256 token with a key id, then verify it through a JWK set with the algorithm, issuer, audience and expiry all required.
import * as jose from "jose";

const { privateKey, publicKey } = await jose.generateKeyPair("ES256");
const jwk = { ...(await jose.exportJWK(publicKey)), kid: "2026-10", alg: "ES256" };

const token = await new jose.SignJWT({ sub: "user-42" })
  .setProtectedHeader({ alg: "ES256", kid: jwk.kid })
  .setIssuer("https://auth.example.com")
  .setAudience("api")
  .setExpirationTime("10m")
  .sign(privateKey);

// The key set picks the key by kid; createRemoteJWKSet(new URL(...)) does the same over HTTPS.
const keys = jose.createLocalJWKSet({ keys: [jwk] });
const { payload } = await jose.jwtVerify(token, keys, {
  algorithms: ["ES256"],
  issuer: "https://auth.example.com",
  audience: "api",
  requiredClaims: ["exp"],
});

console.log("sub:", payload.sub);

The snippet harness runs every sample verbatim in the container image node:24.22.0-bookworm-slim, and the run fails unless the output matches.

Setup
npm init -y >/dev/null && npm install --no-audit --no-fund jose@6.2.13
Command
node usage.mjs
Output must match (regular expression)
sub: user-42

Libraries