Engineering · 2026-04-03
Hybrid Signatures in Practice: Dual-Signing Identity Assertions with ES256 and ML-DSA
By J. W. Bouckaert
One signature is no longer enough
The cryptographic contract underpinning digital identity has always been simple: if you can verify the signature, you can trust the assertion. That contract held for decades because the signature schemes we relied on—RSA, ECDSA, EdDSA—were computationally infeasible to forge with classical hardware.
That assumption is now time-bounded.
NIST's Post-Quantum Cryptography Standardization project finalized FIPS 204 (ML-DSA) in August 2024, formally standardizing lattice-based digital signatures as the replacement for classical schemes vulnerable to Shor's algorithm running on a cryptographically relevant quantum computer (CRQC). The U.S. government set a 2035 deadline for migrating all federal systems to quantum-resistant cryptography. The Harvest Now, Decrypt Later (HNDL) threat means that identity assertions signed today with classical-only schemes can be retroactively forged once a CRQC arrives—potentially invalidating years of audit trails, credential chains, and regulatory compliance artifacts.
But migrating directly from a classical scheme to ML-DSA introduces a different risk: the new scheme may have an undiscovered flaw of its own. Lattice-based cryptography is mature in academic terms—the Learning With Errors (LWE) problem has been studied since Oded Regev's foundational 2005 paper—but no post-quantum scheme has undergone the same 30+ years of real-world adversarial pressure that RSA and elliptic-curve schemes have survived.
The answer is hybrid signatures: every credential carries two independent signatures, computed with fundamentally different mathematical assumptions. If a CRQC breaks the classical elliptic-curve signature, the ML-DSA signature still holds. If a lattice-reduction breakthrough weakens ML-DSA, the classical signature still holds. The credential is valid if and only if both signatures verify.
This article walks through exactly how PasskeyBridge implements this scheme in production.
The hybrid signing architecture
PasskeyBridge's hybrid signature scheme pairs two algorithms:
| Property | ES256 / ECDSA P-256 (Classical) | ML-DSA-65 (Post-Quantum) |
|---|---|---|
| Standard | FIPS 186-5 · RFC 7518 (JWA) | NIST FIPS 204 |
| Mathematical basis | Elliptic curve (NIST P-256 / secp256r1) | Module lattices (Module-LWE) |
| Security level | ~128-bit classical | NIST Level 3 (~192-bit equivalent) |
| Public key size | 65 bytes (uncompressed point) | 1,952 bytes |
| Signature size | 64 bytes (raw r‖s) | ~3,309 bytes |
| Vulnerable to CRQC | Yes (Shor's algorithm) | No (lattice-based) |
| Verifier support | Universal (WebCrypto / JOSE / COSE / VC) | NIST-standardized Aug 2024 |
ES256 for the classical layer
The classical component is ES256—ECDSA over NIST P-256, the algorithm identifier defined in RFC 7518 and standardized in FIPS 186-5. The decisive reason is interoperability: an ES256 JWS is a standard, compact JSON Web Signature that any off-the-shelf JOSE, COSE, or W3C VC verifier can validate against the issuer's published public key—no PasskeyBridge runtime, no bespoke verification library. The classical signature is verifiable by the entire existing ecosystem today.
Two further reasons:
- CNSA 2.0 alignment. ECDSA is the classical algorithm on the NSA's Commercial National Security Algorithm Suite 2.0 transition path—with P-384 at the national-security level, paired with ML-DSA-87. PasskeyBridge issues ES256 (P-256) by default for broad interoperability and bundles ES384 (P-384) on the ML-DSA-87 tier, so that tier carries the complete CNSA 2.0 pair (ECDSA P-384 + ML-DSA-87). EdDSA/Ed25519—elegant as it is—has no CNSA path, which rules it out for the sovereign, defense, and critical-infrastructure tenants PasskeyBridge serves.
- WebCrypto-native. ECDSA P-256 is a first-class primitive in the WebCrypto
SubtleCryptoAPI present in every runtime PasskeyBridge targets (Deno edge functions, browsers, mobile WebViews). EdDSA support across those same runtimes is inconsistent, which is why our verification allowlist rejectsalg: "EdDSA"outright.
The one property Ed25519 is prized for—deterministic per-signature nonces—is a legitimate concern for ECDSA: a reused or predictable nonce k leaks the private key (the canonical example being the PS3 signing-key compromise, where a static k exposed Sony's key). PasskeyBridge signs through the platform WebCrypto implementation, whose k is drawn from a vetted CSPRNG that is further hardened by our QRNG-seeded entropy pipeline; deterministic ECDSA per RFC 6979 is the standardized belt-and-suspenders option for constrained signers. The interoperability the classical layer buys—stock verification against a DID-published key—is worth engineering the nonce path carefully rather than trading the ecosystem away.
ML-DSA-65 (formerly CRYSTALS-Dilithium, Level 3) was chosen over ML-DSA-44 (Level 2) because identity assertions are long-lived artifacts. A credential signed today may need to be verified five or ten years from now—when quantum hardware will be significantly more capable than it is today. The additional ~3 KB of signature overhead per assertion is a negligible cost for decade-scale resilience. For tenants that require NIST Level 5, PasskeyBridge offers ML-DSA-87 as an Enterprise add-on—producing ~4.6 KB signatures with 2,592-byte public keys.
Key management
The two layers use two deliberately different key models—each matched to what it needs.
The classical key: per-tenant ES256, generated and stored
Each tenant has its own P-256 ECDSA key pair. The private key is generated with WebCrypto (crypto.subtle.generateKey), encrypted at rest with AES-256-GCM, and stored in the shield_tenant_keys table; 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 P-256 public key, and verifies the ES256 JWS with a stock library—no shared secret, no PasskeyBridge dependency.
Storing the classical private key (rather than re-deriving it) is what makes it a real asymmetric key that a third party can verify against a published public key. Rotation is atomic with a 24-hour grace window (grace_expires_at): the previous key stays valid for in-flight verifications while new assertions use the new key. A credential issued by Tenant A is cryptographically unverifiable under Tenant B's key.
The post-quantum key: ML-DSA-65, deterministically derived
The ML-DSA-65 key pair is derived deterministically from the platform master secret, so it never has to be stored:
SHIELD_ENCRYPTION_KEY (master secret, base64)
│
HMAC-SHA-256
domain = "pqc-sign:ml-dsa-65:{tenant_id}"
│
32-byte seed (ξ) ──► ML-DSA-65 key pair (FIPS 204 KeyGen)
The domain-separated HMAC-SHA-256 output is a 32-byte seed (denoted ξ in FIPS 204) that KeyGen expands into the full matrix and vector components of the lattice key pair. The same tenant ID and master key always produce the same ML-DSA key pair, so any node with the master key can re-derive it and verify the detached proof—no key database to synchronize or lose. The ML-DSA public key is likewise published in the tenant's DID document for independent post-quantum verification.
This split is intentional: the classical layer optimizes for external verifiability (stored key, published point, stock JOSE), while the post-quantum layer optimizes for operational simplicity (derived, re-derivable, no storage surface). The master secret is protected with AES-256-GCM encryption at rest, hardware-backed secret storage, and rotation policies with configurable TTLs.
Payload and credential structure
A standards-based credential is a compact ES256 JWS—three segments—accompanied by a detached ML-DSA-65 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 a SHA-256 hash of its DID—never by name, email, or phone number (zero-PII). The JWS signing input is base64url(header) + "." + base64url(payload); the ES256 signature over that input is the third JWS segment, verifiable by any stock JOSE library against the DID-published P-256 key.
The post-quantum layer travels detached, returned alongside the credential rather than bundled into the JWS signature segment:
{
"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 is what lets the classical credential stay a standard JWS: verifiers that do not yet speak ML-DSA validate the ES256 JWS and ignore the extension, while PQC-aware verifiers additionally check the detached proof. Both signatures are computed over the same JWS signing input, so a canonicalization mismatch can never make one pass while the other fails for benign reasons.
The signing process
Signing is a parallel operation—the two computations are independent:
JWS signing input (header.payload)
│
┌────────────┴────────────┐
▼ ▼
ECDSA-P256.sign(sk_es, msg) ML-DSA-65.sign(sk_ml, msg + qnonce)
│ │
▼ ▼
ES256 JWS signature (64 B) detached PQC proof (~3,309 B)
│ │
└────────────┬────────────┘
▼
compact JWS + pqc.standard { alg, sig, qn, kid }
The ES256 signature is a standard JWS signature over the exact signing input. The detached ML-DSA-65 signature is fortified with a fresh quantum-seeded nonce (msg + ":qnonce:" + nonce) drawn from the QRNG-backed DRBG, binding the post-quantum proof to a specific point in the entropy timeline and preventing replay.
Performance
Benchmarks on a single Deno edge function instance (ARM64, 256 MB memory):
| Operation | ES256 (P-256) | ML-DSA-65 | Total (parallel) |
|---|---|---|---|
| Sign | ~0.3 ms | ~2.4 ms | ~2.4 ms |
| Verify | ~0.4 ms | ~1.8 ms | ~1.8 ms |
| End-to-end sign | — | — | ~3.6 ms |
| End-to-end verify | — | — | ~3.0 ms |
The ML-DSA-65 operations dominate the timing budget, but the absolute numbers are well within PasskeyBridge's 50-millisecond latency target for identity operations. The parallel execution model means we pay only for the slower of the two algorithms, not the sum.
Verification order and failure semantics
Verification follows a both-must-pass model:
Received credential
│
┌────────────┴────────────┐
▼ ▼
ES256.verify(pk_es, jws) ML-DSA.verify(pk_ml, detached, qnonce)
│ │
▼ ▼
classical_valid: bool pqc_valid: bool
│ │
└────────────┬────────────┘
▼
valid = classical_valid AND pqc_valid
The verification result is a structured object rather than a simple boolean:
{
"valid": false,
"classical_valid": true,
"pqc_valid": false,
"classical_alg": "ES256",
"pqc_algorithm": "ML-DSA-65"
}
This granularity is essential for incident response. When a credential fails verification, the operator needs to know which signature failed—and why.
Failure matrix
| Classical (ES256) | PQC (ML-DSA-65) | Overall | Interpretation | Response |
|---|---|---|---|---|
| ✓ | ✓ | Valid | Normal operation | Accept credential |
| ✓ | ✗ | Invalid | Possible PQC implementation bug, key mismatch, or tampering targeting the lattice component | Reject; log diagnostic; alert engineering |
| ✗ | ✓ | Invalid | Possible quantum-assisted forgery or classical key compromise | Reject; trigger key rotation; escalate to SOC |
| ✗ | ✗ | Invalid | Payload corruption, wrong key, or replay with modified nonce | Reject; rate-limit source; log to audit trail |
The second row—classical passes, PQC fails—is the most operationally common failure mode today, typically indicating a deserialization bug in the PQC verification path rather than an actual attack. The third row—classical fails, PQC passes—is the scenario we are building for: the day a CRQC makes ECDSA forgery feasible. On that day, the PQC signature continues to hold, and the hybrid scheme degrades gracefully to PQC-only validation with a critical alert to begin full classical deprecation.
The quantum transition
The hybrid model provides a concrete, tested migration path for the eventual deprecation of the classical layer:
Phase 1 (Current): Both signatures required. Any verification failure is a hard reject.
Phase 2 (CRQC Imminent): Policy toggle enables "PQC-primary" mode. If classical fails but PQC passes, the credential is accepted with a warning flag while ECDSA is being phased out.
Phase 3 (Post-Transition): Classical signature generation continues for backward compatibility with legacy verifiers, but the platform treats PQC-only verification as sufficient. ES256 becomes advisory.
Phase 4 (Deprecation): Classical signatures are dropped entirely. All assertions carry ML-DSA signatures only. Legacy assertions with both signatures remain verifiable indefinitely because the PQC verification path is backward-compatible.
This four-phase migration is configured per-tenant via the dashboard. The SOC 2 audit controls log every phase transition with the rationale and authorizing user.
The legacy envelope
Credentials issued before the standards-based rollout—and tenants that explicitly opt into the legacy envelope—use a server-verified PQC-HYBRID scheme, where the classical layer is an HMAC-SHA-256 MAC bundled with the ML-DSA proof in the JWT signature segment. That envelope is not stock-JOSE-verifiable (it relies on a server-held key), which is precisely why the standards-based ES256 model is now the default. Verification auto-detects the envelope, so credentials issued under either scheme remain valid; the migration is transparent to relying parties.
Binding hybrid signatures to the three pillars
PasskeyBridge's three-pillar architecture carries quantum-resilient integrity at every trust boundary.
Pillar II: Verifiable credentials
Every W3C VC 2.0 credential issued by the platform's VC Engine carries the dual signature described above: an ES256 JWS (for immediate interoperability with existing VC verifiers) plus a detached ML-DSA-65 proof (for post-quantum verification). Third-party verifiers that do not yet support ML-DSA validate the ES256 JWS alone against the DID-published P-256 key—the PQC component is a forward-compatible extension.
Pillars I & III and cross-reference records
Carrier-signal attestations, passkey attestation envelopes, and the cross-reference bindings that link independent identity proofs are dual-signed for quantum resilience. These internal, server-verified proof objects use the HMAC + ML-DSA hybridSign primitive rather than a published-key JWS—they are audit artifacts and never credentials handed to third-party verifiers—but the same both-must-verify guarantee applies.
Enterprise: ML-DSA-87 (NIST Level 5)
For organizations operating under heightened threat models—defense contractors, sovereign identity programs, critical infrastructure operators—PasskeyBridge supports ML-DSA-87 as an Enterprise add-on.
| Property | ML-DSA-65 (Standard) | ML-DSA-87 (Enterprise) |
|---|---|---|
| NIST security level | Level 3 | Level 5 |
| Equivalent classical security | ~192-bit | ~256-bit |
| Bundled classical curve | ES256 (P-256) | ES384 (P-384)—CNSA 2.0 |
| Public key size | 1,952 bytes | 2,592 bytes |
| Signature size | ~3,309 bytes | ~4,627 bytes |
| Sign latency (edge) | ~2.4 ms | ~3.8 ms |
| Verify latency (edge) | ~1.8 ms | ~2.9 ms |
The level is configured per-tenant via the pqc_level column and is automatically managed via Stripe subscription webhooks. Upgrading from Level 3 to Level 5 takes effect immediately on the next signing operation—the deterministic derivation produces the appropriate ML-DSA key pair for the requested level. The Level 5 tier also bundles the classical layer up to ES384 (ECDSA P-384), completing the CNSA 2.0 pair; because the classical key is a stored asymmetric key (not derived), the new curve applies to keys minted or rotated after the switch, and an existing ES256 key keeps signing ES256 until it is rotated—so a credential's JWS alg always matches its actual key.
Implementation considerations
Signature size budget
The combined hybrid signature for a single credential is approximately:
- ES256 (P-256): 64 bytes (raw r‖s)
- ML-DSA-65: ~3,309 bytes
- Total: ~3,373 bytes (≈3.3 KB)
For ML-DSA-87 tenants, the total rises to ~4,691 bytes (≈4.6 KB). This is larger than a classical-only signature by roughly 50×, but it is still well within the payload limits of HTTP APIs, JWT tokens, and database storage—a negligible cost for decade-scale quantum resilience.
Verification in constrained environments
Mobile wallets and browser-based verifiers may not have ML-DSA verification libraries today. PasskeyBridge handles this via a tiered model:
- Full hybrid verification: Server-side verifiers with ML-DSA support validate both signatures. This is the default for all API-level operations.
- Classical-only verification: Client-side verifiers (e.g., the VC Wallet SDK) validate the ES256 JWS against the DID-published key with a stock JOSE library and treat the detached ML-DSA proof as an opaque, forward-compatible extension (
pqc_verified: false). - PQC-only verification: Reserved for Phase 3+ of the quantum transition, when the classical layer is no longer trusted.
Because the classical layer is a standard ES256 JWS, tier 2 requires no PasskeyBridge-specific code—which is exactly the interoperability dividend that motivated the choice of ES256.
Audit trail integration
Every signing and verification operation is logged to the immutable audit trail with the algorithm pair (ES256 + ML-DSA-65 or ES256 + ML-DSA-87), the key id, the per-component verification result (classical_valid, pqc_valid), the entropy source used for the ML-DSA nonce, and per-component latency. SOC 2 Type II auditors need to verify that cryptographic operations use the declared algorithms, that key-rotation policies are enforced, and that no assertion was signed with a deprecated or compromised key.
Retiring the classical layer
Hybrid signatures are a transitional architecture. The goal is to reach a point where ML-DSA (or its successors) has accumulated enough real-world adversarial pressure that the classical component can be safely retired—likely 10–15 years away.
In the meantime, the dual-signing model provides something that pure classical or pure PQC schemes cannot: cryptographic hedging. No single mathematical assumption needs to hold for the system to remain secure. The ES256 component protects against undiscovered flaws in lattice cryptography and keeps every credential verifiable by the existing ecosystem today. The ML-DSA component protects against quantum computers. Together, they provide defense in depth at the most fundamental layer of the identity stack.
For identity systems—where assertions may need to be verified years or decades after issuance—this is the minimum responsible architecture.
Explore the full PQC architecture → Read about HNDL threats to identity data → Get started with PasskeyBridge →