PasskeyBridge —

Engineering · 2026-04-03

Hybrid Signatures in Practice: Dual-Signing Identity Assertions with ES256 and ML-DSA

By J. W. Bouckaert

Hybrid Signatures in Practice: Dual-Signing Identity Assertions with ES256 and ML-DSA

Two signatures for the quantum transition

Digital identity rests on a simple contract: if you can verify the signature, you can trust the assertion. That contract has held because the signature schemes in use, RSA, ECDSA and EdDSA, cannot be forged with classical computers.

A cryptographically relevant quantum computer (CRQC) running Shor's algorithm would break all three. NIST's Post-Quantum Cryptography Standardization project published FIPS 204 (ML-DSA) in August 2024, standardizing lattice-based digital signatures as a replacement. U.S. National Security Memorandum 10 sets the goal of "mitigating as much of the quantum risk as is feasible by 2035." Harvest Now, Decrypt Later (HNDL) is the risk for encrypted data. For signatures, the risk is different: once a CRQC exists, an attacker can forge new signatures under classical keys, and a classical signature alone can no longer show whether it was made before or after that point.

Moving straight from a classical scheme to ML-DSA has a risk of its own: the new scheme may have an undiscovered flaw. Lattice cryptography is well studied (the Learning With Errors problem dates to Oded Regev's 2005 paper), but no post-quantum scheme has the decades of deployment that RSA and elliptic-curve schemes have.

Hybrid signatures address both risks: a credential carries two independent signatures based on different mathematical assumptions. A CRQC that breaks the elliptic-curve signature leaves the ML-DSA signature intact. A lattice-reduction advance that weakens ML-DSA leaves the classical signature intact.

This article walks through how PasskeyBridge implements the scheme in production.

The hybrid signing architecture

PasskeyBridge pairs two algorithms:

PropertyES256 / ECDSA P-256 (classical)ML-DSA-65 (post-quantum)
StandardFIPS 186-5 · RFC 7518 (JWA)NIST FIPS 204
Mathematical basisElliptic curve (NIST P-256 / secp256r1)Module lattices (Module-LWE)
Security level~128-bit classicalNIST security category 3
Public key size65 bytes (uncompressed point)1,952 bytes
Signature size64 bytes (raw r‖s)3,309 bytes
Vulnerable to a CRQCYes (Shor's algorithm)No known quantum attack
Verifier supportWebCrypto, JOSE, COSE and VC librariesStandardized August 2024; library support growing

ES256 for the classical layer

The classical component is ES256: ECDSA over NIST P-256, the algorithm identifier defined in RFC 7518 and specified in FIPS 186-5. The main reason is interoperability: an ES256 JWS is a standard compact JSON Web Signature that off-the-shelf JOSE and W3C VC verifiers validate against the issuer's published public key, with no PasskeyBridge runtime or custom library.

Two further reasons:

  • The CNSA transition. The NSA's CNSA 1.0 suite specifies ECDSA P-384 for signatures. CNSA 2.0 specifies ML-DSA-87 and deprecates ECDSA once its requirements are mandated. PasskeyBridge issues ES256 (P-256) by default for broad interoperability and uses ES384 (P-384) on the ML-DSA-87 tier, so that tier pairs the CNSA 1.0 classical algorithm with the CNSA 2.0 post-quantum one for the transition period. Ed25519 is in neither suite.
  • WebCrypto support. ECDSA P-256 is a standard primitive in the WebCrypto SubtleCrypto API in every runtime PasskeyBridge uses (Deno edge functions, browsers, mobile WebViews), where Ed25519 support has arrived later and unevenly. PasskeyBridge's credential verifier accepts ES256, ES384 and its legacy envelope, and nothing else.

The property Ed25519 is valued for, deterministic per-signature nonces, matters for ECDSA: a reused or predictable nonce k leaks the private key, as in the PS3 signing-key compromise, where a static k exposed Sony's key. PasskeyBridge signs ES256 through the platform's WebCrypto implementation, which draws k from its own cryptographically secure random number generator. Deterministic ECDSA per RFC 6979 is the standardized option for signers that cannot rely on a good random source.

ML-DSA-65 (formerly CRYSTALS-Dilithium, category 3) was chosen over ML-DSA-44 (category 2) because identity assertions can need to verify for years after issuance. The extra kilobyte or so of signature over ML-DSA-44 is a small cost for that margin. Enterprise tenants can choose ML-DSA-87 (category 5), with 4,627-byte signatures and 2,592-byte public keys.

Key management

The two layers use different key models.

The classical key: per-tenant ECDSA, generated and stored

Each tenant has its own ECDSA key pair: P-256 by default, P-384 on the ML-DSA-87 tier. PasskeyBridge generates it with WebCrypto (crypto.subtle.generateKey), or the tenant imports its own (bring your own key). The private key is encrypted at rest with AES-256-GCM before it is stored; the public key is published as a verification method in the tenant's did:web DID document. A relying party resolves the DID, reads the public key and verifies the JWS with a stock library, with no shared secret and no PasskeyBridge dependency.

Rotation moves the current key into a 24-hour grace period and provisions a new active key, so verifications in flight still find the previous key while new credentials use the new one. A credential issued by Tenant A does not verify under Tenant B's key.

The post-quantum key: ML-DSA, deterministically derived

The ML-DSA key pair is derived from the platform master secret, so its private half is never stored:

SHIELD_ENCRYPTION_KEY (master secret)
         │
    HMAC-SHA-256 over "pqc-sign:ml-dsa:{tenant_id}"
         │
    32-byte seed (ξ) ──► ML-DSA-65 or ML-DSA-87 key pair (FIPS 204 KeyGen)

The domain-separated HMAC-SHA-256 output is a 32-byte seed, written ξ in FIPS 204, which KeyGen expands into the full key pair. The same tenant ID and master secret always produce the same key pair, so any node holding the master secret can re-derive it. The ML-DSA public key is published in the tenant's DID document for independent post-quantum verification.

The split is intentional: the classical layer is built for external verification (a stored key with a published public half and stock JOSE), and the post-quantum layer for operational simplicity (derived, with no private key to store). The cost of derivation is that rotating the master secret changes every tenant's ML-DSA key, so proofs signed before a rotation stop verifying against the new keys. Derivation takes an explicit key version for that reason, so a rotation can keep the earlier version available.

Payload and credential structure

A standards-based credential is a compact ES256 JWS (three segments) accompanied by a detached ML-DSA proof.

JWS header (base64url)         JWS payload (base64url)        JWS signature
{                              {                              ┌──────────────┐
  "alg": "ES256",               "iss": "did:web:…",          │ ECDSA P-256  │
  "typ": "JWT",                 "sub": "did:web:…",          │ over         │
  "kid": "did:web:…#gen-1a2b"   "vc": { … },                 │ header.payload│
}                               "iat": …, "exp": …           └──────────────┘
}

The credential subject is identified by the DID the issuer supplies, which should carry no name, email or phone number (zero-PII); PasskeyBridge stores only a keyed HMAC-SHA-256 digest of it under a server-held pepper. The JWS signing input is base64url(header) + "." + base64url(payload), and the ES256 signature over that input is the third segment.

The post-quantum layer travels detached, returned alongside the credential and stored at issuance:

{
  "alg": "ES256",
  "kid": "did:web:api.passkeybridge.io:tenants:t_abc123#gen-1a2b3c4d",
  "pqc": {
    "alg": "ML-DSA-65",
    "sig": "base64url(ml-dsa-65-signature)",
    "qn":  "hex(quantum_nonce)",
    "kid": "did:web:api.passkeybridge.io:tenants:t_abc123#mldsa-1"
  }
}

Keeping the ML-DSA proof detached lets the credential stay a standard JWS: verifiers that do not support ML-DSA validate the ES256 JWS and ignore the extension, and post-quantum-aware verifiers also check the detached proof. The ES256 signature covers the JWS signing input. The detached ML-DSA signature covers that signing input followed by :qnonce: and a quantum-seeded nonce that travels with the proof.

The signing process

                    JWS signing input (header.payload)
                           │
              ┌────────────┴────────────┐
              ▼                         ▼
     ECDSA-P256.sign(sk_es, msg)   ML-DSA-65.sign(sk_ml, msg + ":qnonce:" + nonce)
              │                         │
              ▼                         ▼
     ES256 JWS signature (64 B)   detached PQC proof (3,309 B)
              │                         │
              └────────────┬────────────┘
                           ▼
            compact JWS  +  standard { alg, kid, pqc { alg, sig, qn, kid } }

The ES256 signature is a standard JWS signature over the signing input. The ML-DSA signature covers the signing input plus a fresh nonce from the QRNG-backed DRBG, which ties the proof to a recorded draw from the entropy pipeline.

For credentials, the ES256 signature and the ML-DSA proof are computed one after the other. The server-side hybridSign primitive used for internal records computes its two layers in parallel.

Verification and failure semantics

PasskeyBridge's verifier checks the ES256 signature against the issuer's published key, then looks for the detached proof: one the caller supplies, or the one stored at issuance. The result reports both:

{
  "valid": false,
  "checks": { "signature_valid": false },
  "post_quantum": { "checked": true, "verified": false, "source": "stored" }
}

The rules:

ES256Detached ML-DSA proofResult
VerifiesFound and verifiesValid
VerifiesFound and failsInvalid
VerifiesNot available to the verifierValid on the classical signature, with post_quantum.checked: false
FailsAnyInvalid

A proof that is missing does not make a credential invalid. A foreign issuer's credential, or one issued before proofs were stored, has a good classical signature and no proof to check, and the post_quantum field says so. One recorded exception applies: credentials issued before a 2026-09-08 rotation of the master secret are accepted on their classical signature, with post_quantum.orphaned set, because their proofs can no longer verify against the current keys.

Suggested operator responses:

PatternLikely causeResponse
ES256 passes, proof failsImplementation or key-version mismatch, or tampering with the proofReject; investigate the proof path
ES256 fails, proof passesClassical key compromise, or in future a quantum-assisted forgeryReject; rotate the tenant key
Both failCorruption, wrong key or a modified payloadReject; review the source in the audit trail

The quantum transition

Today, a credential whose proof the verifier can reach must pass both signatures. When a CRQC makes ECDSA forgery practical, a classical signature will prove nothing on its own, and verifiers will need to require the post-quantum layer. PasskeyBridge does not yet offer a post-quantum-only verification mode.

The legacy envelope

Credentials issued before the standards-based rollout, and tenants that explicitly choose it, use a server-verified PQC-HYBRID envelope: an HMAC-SHA-256 classical layer bundled with the ML-DSA signature in the JWT signature segment. That envelope cannot be verified with stock JOSE, because the HMAC key is server-held, which is why the ES256 model is the default. When a tenant's ECDSA key cannot be provisioned, issuance is refused and the request can be retried; it does not fall back to the legacy envelope. The verifier reads the header's alg and handles either format.

Hybrid signatures across the three pillars

Pillar II: Verifiable Credentials

Every standards-based W3C Verifiable Credential PasskeyBridge issues carries the ES256 JWS plus the detached ML-DSA proof described above. Third-party verifiers without ML-DSA support validate the ES256 JWS against the DID-published key.

Internal records

Cross-reference bindings and cached proofs are signed with hybridSign: an HMAC-SHA-256 layer and an ML-DSA signature. The five-minute checkpoints of the audit hash chain also carry an HMAC-SHA-256 and an ML-DSA-65 signature. These are records that the server verifies. For cross-references, a failed signature check is reported as pqc_verified: false alongside the result.

Enterprise: ML-DSA-87 (category 5)

Enterprise tenants can choose ML-DSA-87:

PropertyML-DSA-65 (standard)ML-DSA-87 (Enterprise)
NIST security category35
Classical layerES256 (P-256)ES384 (P-384)
Public key size1,952 bytes2,592 bytes
Signature size3,309 bytes4,627 bytes

The level is stored per tenant in pqc_level and set from the tenant's Stripe subscription. A level change applies to the next signing operation, because derivation produces the key pair for the requested level. The classical curve follows the stored key: an existing ES256 key keeps signing ES256 until it is rotated to P-384, so a credential's JWS alg always matches its key.

Implementation considerations

Signature size budget

The combined signatures for one credential:

  • ES256 (P-256): 64 bytes; ML-DSA-65: 3,309 bytes; total 3,373 bytes
  • ES384 (P-384): 96 bytes; ML-DSA-87: 4,627 bytes; total 4,723 bytes

That is roughly 50 times a classical-only signature, and well within the limits of HTTP APIs, JWTs and database rows.

Verification without ML-DSA support

Mobile wallets and browser verifiers may not have ML-DSA libraries yet:

  1. Hybrid verification: PasskeyBridge's own verifier checks both signatures when the proof is available.
  2. Classical-only verification: a stock JOSE verifier checks the ES256 JWS against the DID-published key and ignores the detached proof.

Because the classical layer is a standard ES256 JWS, classical-only verification needs no PasskeyBridge-specific code.

Audit trail

Each issuance writes an audit entry with the credential type, format, ML-DSA algorithm and a keyed digest of the subject to the hash-chained audit log. SOC 2 Type II auditors look for evidence that cryptographic operations use the declared algorithms and that key rotations follow policy.

Retiring the classical layer

Hybrid signatures are a transitional design. The classical component can be retired once ML-DSA, or a successor, has enough deployment history, and once classical signatures no longer protect anything because a CRQC exists.

Until then, the dual-signing model hedges between two assumptions. The ES256 component guards against an undiscovered flaw in lattice cryptography and keeps every credential verifiable by today's ecosystem. The ML-DSA component guards against quantum computers.

Explore the full PQC architecture Read about HNDL threats to identity data Get started with PasskeyBridge

Start free · Test the API