What the gem protects against
- Quantum forgery of signatures. ML-DSA is designed to resist both classical and quantum adversaries at NIST levels 2, 3, and 5. jwt-pq delegates every cryptographic primitive to liboqs — it does not reimplement crypto.
-
Algorithm confusion. Decoding requires an explicit
algorithms:allowlist. Tokens signed with an algorithm outside that list are rejected before signature verification ever runs. -
Key-material lifetime.
Key#destroy!zeroes the secret buffer in both Ruby and FFI memory. A GC finalizer wipes FFI buffers as a safety net if a key is garbage-collected without an explicitdestroy!. For hybrid keys,destroy!also wipes the Ed25519 keypair. -
Thread safety for signing. Every
sign/verify/destroy on a given key instance is serialized on a
per-instance mutex. A concurrent
destroy!cannot race with an in-flight operation — the destroy waits. Read-only accessors like#public_keyand#jwk_thumbprintdo not take the mutex. - Hybrid degradation. In hybrid mode, both the Ed25519 and ML-DSA halves must verify. A break in either algorithm alone still leaves the token authenticated.
-
Remote JWKS hardening.
JWT::PQ::JWKSet.fetchenforces HTTPS, rejects redirects, caps response bodies (1 MB default), applies 5 s open/read timeouts, streams with the body cap honoured during read, and surfaces fetch failures asJWT::PQ::JWKSFetchError(separate fromKeyErrorfor malformed JWKS).
What the gem does not protect against
-
Compromised signing keys. If an attacker steals
your private key, they can mint valid tokens. Rotate keys, limit
their blast radius, revoke via
kidat the verifier. -
Replay attacks. jwt-pq verifies signatures. It
does not track token usage. Use
jti,nonce, or shortexpwindows in your application layer. - Authorization. A valid signature proves origin and integrity, not that the bearer is permitted to perform an action.
-
Clock skew. jwt-pq inherits ruby-jwt's
exp/nbf/iathandling. Pick a leeway that matches your infrastructure. - Side channels beyond liboqs's own hardening. The gem adds no constant-time guarantees of its own; it also adds no variable-time code paths over key or signature bytes.
-
Fork-safety of in-flight signing. A
Mutexheld by a live thread atfork(2)time is inherited locked-with-no-owner in the child. Fork workers before any thread begins signing on a shared key, or construct keys post-fork.
Operational guidance
- Call
Key#destroy!when a key is no longer needed — especially in long-running processes where the buffer would otherwise sit in memory indefinitely. - Use
JWKSet.fetchrather than hand-rolled HTTP for remote JWKS. The defaults are deliberately conservative; raise the limits only after you understand them. - Publish
kidon every token (JWT.encode(payload, key, alg, { kid: key.jwk_thumbprint })) so verifiers can select the right key during rotation. - Pin the
algorithms:array on the verifier side. Never accept "whatever the header says" without an allowlist.
Reporting a vulnerability
Please disclose security issues privately via the GitHub Security Advisories workflow on the jwt-pq repository. See SECURITY.md for the supported-versions policy and how upstream liboqs advisories are handled.