threat model

What the gem does and does not protect.

jwt-pq is a signing layer. It resists quantum forgery of signatures, zeroes key material on destruction, and hardens the JWKS fetch path. Everything else — replay, authorization, clock skew — stays in your application.

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 explicit destroy!. 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_key and #jwk_thumbprint do 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.fetch enforces 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 as JWT::PQ::JWKSFetchError (separate from KeyError for 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 kid at the verifier.
  • Replay attacks. jwt-pq verifies signatures. It does not track token usage. Use jti, nonce, or short exp windows 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 / iat handling. 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 Mutex held by a live thread at fork(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.fetch rather than hand-rolled HTTP for remote JWKS. The defaults are deliberately conservative; raise the limits only after you understand them.
  • Publish kid on 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.