
A JWT module for Deno that signs and verifies tokens using ML-DSA (FIPS 204) or a hybrid ML-DSA + Ed25519 scheme for defence-in-depth during the post-quantum transition.
Features
- ML-DSA only —
ML-DSA-44/ML-DSA-65/ML-DSA-87via@oqs/liboqs-js - Hybrid mode — ML-DSA + Ed25519 signed in parallel. Both signatures must verify. If either algorithm is later broken (classical or quantum), the other still protects the token.
- Standard JWT claims:
exp,iat,iss,sub - Zero-dependency verification helpers, typed errors via
JwtError
Install
deno add jsr:@netopwibby/pq-jwt
Or import directly:
import { sign, verify } from "jsr:@netopwibby/pq-jwt";
Usage
ML-DSA only
import { generateKeyPair, sign, verify } from "jsr:@netopwibby/pq-jwt";
const { publicKey, secretKey } = await generateKeyPair("ML-DSA-65");
const token = await sign(
{
role: "member",
sub: "user_123"
},
secretKey,
{
algorithm: "ML-DSA-65",
expiresIn: 3600,
issuer: "kern.neue"
}
);
const claims = await verify(token, publicKey, {
algorithm: "ML-DSA-65",
issuer: "kern.neue"
});
console.log(claims.sub); // "user_123"
Hybrid ML-DSA + Ed25519
import {
generateHybridKeyPair,
hybridSign,
hybridVerify
} from "jsr:@netopwibby/pq-jwt";
const keys = await generateHybridKeyPair("ML-DSA-65");
const token = await hybridSign(
{
role: "admin",
sub: "user_123"
},
keys,
{
expiresIn: 3600,
issuer: "kern.neue"
}
);
const claims = await hybridVerify(
token,
{
ed25519PublicKey: keys.ed25519.publicKey,
mlDsaPublicKey: keys.mlDsa.publicKey
},
{
issuer: "kern.neue",
variant: "ML-DSA-65"
}
);
Both signatures must verify. If either fails, hybridVerify throws JwtError.
Decode without verifying
import { decode } from "jsr:@netopwibby/pq-jwt";
const { header, payload } = decode(token);
Use only for inspection. Never use the result for authorization.
Error handling
import { JwtError, verify } from "jsr:@netopwibby/pq-jwt";
try {
await verify(token, publicKey);
} catch (err) {
if (err instanceof JwtError) {
console.error(err.code, err.message);
// ERR_MALFORMED, ERR_TYPE, ERR_ALGORITHM,
// ERR_SIGNATURE, ERR_SIGNATURE_MLDSA, ERR_SIGNATURE_ED25519,
// ERR_EXPIRED, ERR_ISSUER, ERR_SUBJECT
}
}
API
| Export | Description |
|---|---|
generateKeyPair(variant?) |
Generate an ML-DSA keypair (default ML-DSA-65) |
generateHybridKeyPair(variant?) |
Generate a hybrid ML-DSA + Ed25519 keypair |
sign(payload, secretKey, options?) |
Sign a payload with ML-DSA |
verify(token, publicKey, options?) |
Verify an ML-DSA JWT |
hybridSign(payload, keyPair, options?) |
Sign with both ML-DSA and Ed25519 |
hybridVerify(token, publicKeys, options?) |
Verify a hybrid JWT (both must pass) |
decode(token) |
Decode header + payload without verifying |
KEY_SIZES |
Raw byte sizes of keys per variant |
JwtError |
Error thrown on any verification failure |
Key sizes (raw bytes)
| Variant | ML-DSA public | ML-DSA secret | Ed25519 public | Ed25519 secret |
|---|---|---|---|---|
| ML-DSA-44 | 1312 | 2560 | 32 | 32 |
| ML-DSA-65 | 1952 | 4032 | 32 | 32 |
| ML-DSA-87 | 2592 | 4896 | 32 | 32 |
ML-DSA signature lengths: 44 → 2420, 65 → 3293, 87 → 4595 bytes.
Hybrid signature layout
[0..3] uint32 big-endian ML-DSA signature length
[4..4+mlDsaSigLen-1] ML-DSA signature bytes
[4+mlDsaSigLen..] Ed25519 signature bytes (always 64)
The entire blob is Base64url-encoded as the JWT signature segment.
Run the example
deno run example.ts
Security notes
@oqs/liboqs-js is research/prototyping software and has not been formally audited. The hybrid mode provides defence-in-depth during the PQC transition. Follow NIST PQC guidance for production deployments.
License
MIT