Quantum computers capable of breaking RSA and elliptic-curve cryptography don't exist yet, but the threat is real enough that NIST finalized three post-quantum cryptography (PQC) standards in 2024: FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA). The concern isn't just future decryption. "Harvest now, decrypt later" attacks mean that encrypted data captured today could be broken once quantum hardware catches up.
.NET 10 ships first-class support for all three FIPS-standardized PQC algorithms, plus a hybrid approach called Composite ML-DSA that combines classical and post-quantum signatures. No hand-waving. Let's write code.
Platform Support and Runtime Detection
Before diving into each algorithm, a quick note on where these APIs actually work. PQC support in .NET 10 requires one of:
- Linux with OpenSSL 3.5 or newer
- Windows with CNG PQC support
Note: PQC is not supported on macOS in .NET 10. .NET uses Apple's native security framework on macOS, not OpenSSL, so having OpenSSL 3.5+ installed via Homebrew won't help. Apple's frameworks gained PQC support in Platform 26, but that shipped after .NET 10. CryptoKit-based PQC support is tracked for .NET 11. If you're on a Mac, the easiest path is Docker. See the Try It Yourself section at the end of this post.
Each type exposes a static IsSupported property you should check before using the algorithm:
using System.Security.Cryptography;
Console.WriteLine($"ML-KEM: {MLKem.IsSupported}");
Console.WriteLine($"ML-DSA: {MLDsa.IsSupported}");
Console.WriteLine($"SLH-DSA: {SlhDsa.IsSupported}");
Console.WriteLine($"Composite ML-DSA:{CompositeMLDsa.IsSupported}");
if (!MLKem.IsSupported)
{
Console.WriteLine("PQC requires OpenSSL 3.5+ (Linux) or Windows CNG.");
return;
}
// ... run demos
One important design note: these new types don't inherit from AsymmetricAlgorithm. Instead of creating an object and then importing a key into it, you use static methods to generate or import keys directly. This is a cleaner pattern that the .NET team adopted for all PQC types.
ML-KEM: Post-Quantum Key Encapsulation (FIPS 203)
ML-KEM (Module-Lattice-Based Key Encapsulation Mechanism) replaces classical key exchange algorithms like Diffie-Hellman and ECDH. It lets two parties establish a shared secret over an insecure channel.
The flow works like this: Alice generates a key pair and shares her public key. Bob uses that public key to produce both a ciphertext and a shared secret. Alice then decapsulates the ciphertext with her private key to recover the same shared secret.
using System.Security.Cryptography;
// Alice generates a key pair
using MLKem aliceKey = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);
// Alice shares her public (encapsulation) key with Bob
byte[] alicePublicKey = aliceKey.ExportEncapsulationKey();
Console.WriteLine($"Alice's public key: {alicePublicKey.Length} bytes");
// Bob uses Alice's public key to create a shared secret + ciphertext
using MLKem bobKey = MLKem.ImportEncapsulationKey(
MLKemAlgorithm.MLKem768, alicePublicKey);
bobKey.Encapsulate(out byte[] ciphertext, out byte[] bobSharedSecret);
Console.WriteLine($"Bob's shared secret: {Convert.ToHexString(bobSharedSecret[..16])}...");
// Alice decapsulates the ciphertext to get the same shared secret
byte[] aliceSharedSecret = aliceKey.Decapsulate(ciphertext);
Console.WriteLine($"Alice's shared secret: {Convert.ToHexString(aliceSharedSecret[..16])}...");
// Both sides now have the same shared secret
bool match = aliceSharedSecret.AsSpan().SequenceEqual(bobSharedSecret);
Console.WriteLine($"Shared secrets match: {match}");
MLKemAlgorithm offers three parameter sets:
| Parameter Set | Security Level | Public Key Size | Ciphertext Size |
|---|---|---|---|
| MLKem512 | NIST Level 1 | 800 bytes | 768 bytes |
| MLKem768 | NIST Level 3 | 1,184 bytes | 1,088 bytes |
| MLKem1024 | NIST Level 5 | 1,568 bytes | 1,568 bytes |
MLKem768 is a reasonable default for most applications, balancing security and performance.
MLKem is the only PQC type in .NET 10 that is not marked as [Experimental]. Its API surface is considered stable.
ML-DSA: Post-Quantum Digital Signatures (FIPS 204)
ML-DSA (Module-Lattice-Based Digital Signature Algorithm) is the post-quantum replacement for RSA and ECDSA signatures. If you sign tokens, documents, or messages today, you'll eventually migrate to ML-DSA.
using System.Security.Cryptography;
using System.Text;
// Generate a signing key
using MLDsa signingKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
// Sign some data
byte[] data = Encoding.UTF8.GetBytes("Transfer $1,000 to account 12345");
byte[] signature = signingKey.SignData(data);
Console.WriteLine($"Data: {Encoding.UTF8.GetString(data)}");
Console.WriteLine($"Signature: {signature.Length} bytes");
// Verify with the same key (has both private and public)
bool valid = signingKey.VerifyData(data, signature);
Console.WriteLine($"Signature valid: {valid}");
// Export the public key and verify with a separate key instance
string publicKeyPem = signingKey.ExportSubjectPublicKeyInfoPem();
using MLDsa verifyKey = MLDsa.ImportFromPem(publicKeyPem);
bool validFromPem = verifyKey.VerifyData(data, signature);
Console.WriteLine($"Verified from exported PEM: {validFromPem}");
// Tamper with the data and verify again
byte[] tamperedData = Encoding.UTF8.GetBytes("Transfer $9,999 to account 12345");
bool tamperedValid = verifyKey.VerifyData(tamperedData, signature);
Console.WriteLine($"Tampered data valid: {tamperedValid}");
Notice how SignData returns a byte[] directly. Earlier previews required you to pre-allocate a buffer and pass it in. The .NET team added these convenience overloads to simplify common patterns.
MLDsaAlgorithm offers three parameter sets:
| Parameter Set | Security Level | Public Key Size | Signature Size |
|---|---|---|---|
| MLDsa44 | NIST Level 2 | 1,312 bytes | 2,420 bytes |
| MLDsa65 | NIST Level 3 | 1,952 bytes | 3,309 bytes |
| MLDsa87 | NIST Level 5 | 2,592 bytes | 4,627 bytes |
HashML-DSA (Pre-Hash Mode)
ML-DSA also supports a "pre-hash" mode where you hash the data first, then sign the hash. This is useful when the data is large or when you need to sign with a specific hash algorithm. The API takes the hash algorithm's OID as a string. One catch: the hash algorithm must meet or exceed the security level of the ML-DSA parameter set. ML-DSA-65 is NIST Level 3, so SHA-384, SHA-512, SHA3-384, SHA3-512, or SHAKE256 work. SHA-256 and SHA3-256 are too weak.
using System.Security.Cryptography;
byte[] SignPreHashSha512(MLDsa signingKey, ReadOnlySpan<byte> data)
{
const string Sha512Oid = "2.16.840.1.101.3.4.2.3";
return signingKey.SignPreHash(SHA512.HashData(data), Sha512Oid);
}
MLDsa is marked as [Experimental] under diagnostic SYSLIB5006 until the underlying standards work is complete.
SLH-DSA: Stateless Hash-Based Signatures (FIPS 205)
SLH-DSA (Stateless Hash-Based Digital Signature Algorithm) takes a different approach than ML-DSA. Where ML-DSA relies on lattice mathematics, SLH-DSA is built entirely on hash functions. The trade-off: signatures are larger, but the security argument rests on hash function properties that cryptographers have studied for decades.
using System.Security.Cryptography;
using System.Text;
// SLH-DSA uses hash-based cryptography (no lattice math)
// Larger signatures, but based on well-understood hash functions
using SlhDsa signingKey = SlhDsa.GenerateKey(SlhDsaAlgorithm.SlhDsaSha2_128f);
byte[] data = Encoding.UTF8.GetBytes("Critical audit log entry #4821");
byte[] signature = signingKey.SignData(data);
Console.WriteLine($"Data: {Encoding.UTF8.GetString(data)}");
Console.WriteLine($"Signature: {signature.Length} bytes (larger than ML-DSA)");
bool valid = signingKey.VerifyData(data, signature);
Console.WriteLine($"Signature valid: {valid}");
The f and s suffixes in algorithm names like SlhDsaSha2_128f indicate speed trade-offs:
f(fast): Faster signing, larger signaturess(small): Slower signing, smaller signatures
Choose SLH-DSA when you want defense-in-depth. If lattice-based assumptions (used by ML-DSA) are ever broken, SLH-DSA still stands because it relies only on hash functions.
SlhDsa is also marked as [Experimental] under SYSLIB5006.
Composite ML-DSA: The Migration-Safe Approach
Composite ML-DSA combines a classical signature algorithm (such as RSA) with a post-quantum algorithm (ML-DSA) into a single signature scheme. If either algorithm is broken, the other still protects you. This is the pragmatic choice for systems that need quantum resistance today but can't afford to bet everything on a single new algorithm.
using System.Security.Cryptography;
using System.Text;
// Combine ML-DSA with RSA for a migration-safe approach.
// If either algorithm is broken, the other still protects you.
var algorithm = CompositeMLDsaAlgorithm.MLDsa65WithRSA4096Pss;
using CompositeMLDsa privateKey = CompositeMLDsa.GenerateKey(algorithm);
byte[] data = Encoding.UTF8.GetBytes("High-value token payload");
byte[] signature = privateKey.SignData(data);
Console.WriteLine($"Data: {Encoding.UTF8.GetString(data)}");
Console.WriteLine($"Composite signature: {signature.Length} bytes");
// Export the composite public key and verify
byte[] compositePublicKey = privateKey.ExportCompositeMLDsaPublicKey();
using CompositeMLDsa publicKey = CompositeMLDsa.ImportCompositeMLDsaPublicKey(
algorithm, compositePublicKey);
bool valid = publicKey.VerifyData(data, signature);
Console.WriteLine($"Composite signature valid: {valid}");
// Tamper with the signature
signature[0] ^= 1;
bool tamperedValid = publicKey.VerifyData(data, signature);
Console.WriteLine($"Tampered signature valid: {tamperedValid}");
The CompositeMLDsaAlgorithm type provides RSA-based composite options. .NET 10 ships with the RSA variants implemented:
| Algorithm | Components |
|---|---|
| MLDsa44WithRSA2048Pss | ML-DSA-44 + RSA-2048-PSS |
| MLDsa65WithRSA3072Pss | ML-DSA-65 + RSA-3072-PSS |
| MLDsa65WithRSA4096Pss | ML-DSA-65 + RSA-4096-PSS |
This implements draft-ietf-lamps-pq-composite-sigs. The draft is still evolving (draft 15 at the time of writing), so expect the .NET implementation to keep pace with updates in future releases.
CompositeMLDsa is marked as [Experimental] under SYSLIB5006.
Choosing the Right Algorithm
| Scenario | Recommended | Why |
|---|---|---|
| Key exchange / key agreement | ML-KEM (768) | Replaces ECDH; stable API, not experimental |
| General signing (new systems) | ML-DSA (65) | Smallest signatures of the PQC options; best for most use cases |
| Maximum hash-based confidence | SLH-DSA | No reliance on lattice math; conservative choice |
| Migration from RSA/ECDSA | Composite ML-DSA | Both classical and PQC protection during transition |
| Signing where backward compatibility matters | Composite ML-DSA | Classical verifiers can still validate the RSA component |
What This Means for Identity Infrastructure
Token signing is at the heart of modern identity systems. Every JWT, every SAML assertion, every OpenID Connect (OIDC) ID token carries a digital signature that clients verify. Today, those signatures use RSA or ECDSA. Eventually, they will need to support post-quantum algorithms.
That transition won't happen overnight. Certificate authorities are beginning PQC trials. Standards bodies are working on PQC-aware token formats. But the building blocks are now available in .NET 10, and the time to start experimenting is now.
Key rotation strategies become especially important during algorithm migration. A system that can smoothly transition from RSA to ML-DSA (or use Composite ML-DSA during the transition) is better positioned than one with hardcoded assumptions about the algorithm. Duende IdentityServer's automatic key management is designed to be algorithm-agnostic, handling signing key lifecycle regardless of the underlying algorithm. As PQC support stabilizes across the ecosystem, that flexibility matters.
Production Considerations
The code in this post is for learning and experimentation. If you adapt these patterns for production use, keep in mind:
- Never log or display shared secrets or private key material. The ML-KEM demo prints truncated shared secrets to show that both sides match. In a real system, those values go straight into a KDF or symmetric cipher.
- Zero sensitive byte arrays when you're done with them. Call
CryptographicOperations.ZeroMemory()on shared secrets, plaintext key material, and signature buffers that contain sensitive data. - Three of the four PQC APIs are experimental. ML-DSA, SLH-DSA, and Composite ML-DSA are all marked
[Experimental]. Their API surface may change before stabilizing. ML-KEM is the only stable PQC type in .NET 10.
Try It Yourself
Everything you need to run these demos is right here. Create three files in an empty directory:
PostQuantumCrypto.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- ML-DSA, SLH-DSA, and CompositeMLDsa are experimental in .NET 10 -->
<NoWarn>SYSLIB5006</NoWarn>
</PropertyGroup>
</Project>
Program.cs
using System.Security.Cryptography;
using System.Text;
Console.WriteLine("=== Post-Quantum Cryptography in .NET 10 ===\n");
// Check platform support before running any demos
Console.WriteLine("Platform support:");
Console.WriteLine($" ML-KEM: {MLKem.IsSupported}");
Console.WriteLine($" ML-DSA: {MLDsa.IsSupported}");
Console.WriteLine($" SLH-DSA: {SlhDsa.IsSupported}");
Console.WriteLine($" Composite ML-DSA:{CompositeMLDsa.IsSupported}");
Console.WriteLine();
if (!MLKem.IsSupported)
{
Console.WriteLine("PQC algorithms require OpenSSL 3.5+ (Linux) or Windows CNG with PQC support.");
Console.WriteLine("Exiting.");
return;
}
MlKemDemo();
MlDsaDemo();
HashMlDsaDemo();
SlhDsaDemo();
CompositeMlDsaDemo();
// ──────────────────────────────────────────────
// ML-KEM: Post-Quantum Key Encapsulation
// ──────────────────────────────────────────────
void MlKemDemo()
{
Console.WriteLine("── ML-KEM (FIPS 203): Key Encapsulation ──\n");
// Alice generates a key pair
using MLKem aliceKey = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);
// Alice shares her public (encapsulation) key with Bob
byte[] alicePublicKey = aliceKey.ExportEncapsulationKey();
Console.WriteLine($"Alice's public key: {alicePublicKey.Length} bytes");
// Bob uses Alice's public key to create a shared secret + ciphertext
using MLKem bobKey = MLKem.ImportEncapsulationKey(
MLKemAlgorithm.MLKem768, alicePublicKey);
bobKey.Encapsulate(out byte[] ciphertext, out byte[] bobSharedSecret);
Console.WriteLine($"Bob's shared secret: {Convert.ToHexString(bobSharedSecret[..16])}...");
// Alice decapsulates the ciphertext to get the same shared secret
byte[] aliceSharedSecret = aliceKey.Decapsulate(ciphertext);
Console.WriteLine($"Alice's shared secret: {Convert.ToHexString(aliceSharedSecret[..16])}...");
// Both sides now have the same shared secret
bool match = aliceSharedSecret.AsSpan().SequenceEqual(bobSharedSecret);
Console.WriteLine($"Shared secrets match: {match}\n");
}
// ──────────────────────────────────────────────
// ML-DSA: Post-Quantum Digital Signatures
// ──────────────────────────────────────────────
void MlDsaDemo()
{
Console.WriteLine("── ML-DSA (FIPS 204): Digital Signatures ──\n");
// Generate a signing key
using MLDsa signingKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
// Sign some data
byte[] data = Encoding.UTF8.GetBytes("Transfer $1,000 to account 12345");
byte[] signature = signingKey.SignData(data);
Console.WriteLine($"Data: {Encoding.UTF8.GetString(data)}");
Console.WriteLine($"Signature: {signature.Length} bytes");
// Verify with the same key (has both private and public)
bool valid = signingKey.VerifyData(data, signature);
Console.WriteLine($"Signature valid: {valid}");
// Export the public key and verify with a separate key instance
string publicKeyPem = signingKey.ExportSubjectPublicKeyInfoPem();
using MLDsa verifyKey = MLDsa.ImportFromPem(publicKeyPem);
bool validFromPem = verifyKey.VerifyData(data, signature);
Console.WriteLine($"Verified from exported PEM: {validFromPem}");
// Tamper with the data and verify again
byte[] tamperedData = Encoding.UTF8.GetBytes("Transfer $9,999 to account 12345");
bool tamperedValid = verifyKey.VerifyData(tamperedData, signature);
Console.WriteLine($"Tampered data valid: {tamperedValid}\n");
}
// ──────────────────────────────────────────────
// HashML-DSA: Pre-Hash Mode for ML-DSA
// ──────────────────────────────────────────────
void HashMlDsaDemo()
{
Console.WriteLine("── HashML-DSA: Pre-Hash Mode ──\n");
using MLDsa signingKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
byte[] data = Encoding.UTF8.GetBytes("Large document content that benefits from pre-hashing");
// Pre-hash the data with SHA-512, then sign the hash.
// The hash algorithm must meet or exceed the ML-DSA security level.
// ML-DSA-65 is NIST Level 3, so SHA-384, SHA-512, SHA3-384, SHA3-512, or SHAKE256 work.
// SHA-256 and SHA3-256 are too weak for ML-DSA-65.
byte[] signature = SignPreHashSha512(signingKey, data);
Console.WriteLine($"Pre-hash signature: {signature.Length} bytes\n");
}
byte[] SignPreHashSha512(MLDsa signingKey, ReadOnlySpan<byte> data)
{
const string Sha512Oid = "2.16.840.1.101.3.4.2.3";
return signingKey.SignPreHash(SHA512.HashData(data), Sha512Oid);
}
// ──────────────────────────────────────────────
// SLH-DSA: Stateless Hash-Based Signatures
// ──────────────────────────────────────────────
void SlhDsaDemo()
{
Console.WriteLine("── SLH-DSA (FIPS 205): Hash-Based Signatures ──\n");
// SLH-DSA uses hash-based cryptography (no lattice math)
// Larger signatures, but based on well-understood hash functions
using SlhDsa signingKey = SlhDsa.GenerateKey(SlhDsaAlgorithm.SlhDsaSha2_128f);
byte[] data = Encoding.UTF8.GetBytes("Critical audit log entry #4821");
byte[] signature = signingKey.SignData(data);
Console.WriteLine($"Data: {Encoding.UTF8.GetString(data)}");
Console.WriteLine($"Signature: {signature.Length} bytes (larger than ML-DSA)");
bool valid = signingKey.VerifyData(data, signature);
Console.WriteLine($"Signature valid: {valid}\n");
}
// ──────────────────────────────────────────────
// Composite ML-DSA: Hybrid Classical + PQC
// ──────────────────────────────────────────────
void CompositeMlDsaDemo()
{
Console.WriteLine("── Composite ML-DSA: Hybrid Signatures ──\n");
// Combine ML-DSA with RSA for a migration-safe approach.
// If either algorithm is broken, the other still protects you.
var algorithm = CompositeMLDsaAlgorithm.MLDsa65WithRSA4096Pss;
using CompositeMLDsa privateKey = CompositeMLDsa.GenerateKey(algorithm);
byte[] data = Encoding.UTF8.GetBytes("High-value token payload");
byte[] signature = privateKey.SignData(data);
Console.WriteLine($"Data: {Encoding.UTF8.GetString(data)}");
Console.WriteLine($"Composite signature: {signature.Length} bytes");
// Export the composite public key and verify
byte[] compositePublicKey = privateKey.ExportCompositeMLDsaPublicKey();
using CompositeMLDsa publicKey = CompositeMLDsa.ImportCompositeMLDsaPublicKey(
algorithm, compositePublicKey);
bool valid = publicKey.VerifyData(data, signature);
Console.WriteLine($"Composite signature valid: {valid}");
// Tamper with the signature
signature[0] ^= 1;
bool tamperedValid = publicKey.VerifyData(data, signature);
Console.WriteLine($"Tampered signature valid: {tamperedValid}\n");
}
Dockerfile
The Alpine-based .NET images ship with OpenSSL 3.5, so PQC works out of the box. This is the easiest way to run the demos on macOS or any system without OpenSSL 3.5+.
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /app
COPY *.csproj ./
RUN dotnet restore
COPY *.cs ./
RUN dotnet build -c Release --no-restore
FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine
WORKDIR /app
COPY --from=build /app/bin/Release/net10.0/ ./
ENTRYPOINT ["dotnet", "PostQuantumCrypto.dll"]
Build and run with Docker:
docker build -t pqc-demo .
docker run --rm pqc-demo
Sample Output
=== Post-Quantum Cryptography in .NET 10 ===
Platform support:
ML-KEM: True
ML-DSA: True
SLH-DSA: True
Composite ML-DSA:True
── ML-KEM (FIPS 203): Key Encapsulation ──
Alice's public key: 1184 bytes
Bob's shared secret: AE8446438B3840C519100D63F95B9484...
Alice's shared secret: AE8446438B3840C519100D63F95B9484...
Shared secrets match: True
── ML-DSA (FIPS 204): Digital Signatures ──
Data: Transfer $1,000 to account 12345
Signature: 3309 bytes
Signature valid: True
Verified from exported PEM: True
Tampered data valid: False
── HashML-DSA: Pre-Hash Mode ──
Pre-hash signature: 3309 bytes
── SLH-DSA (FIPS 205): Hash-Based Signatures ──
Data: Critical audit log entry #4821
Signature: 17088 bytes (larger than ML-DSA)
Signature valid: True
── Composite ML-DSA: Hybrid Signatures ──
Data: High-value token payload
Composite signature: 3821 bytes
Composite signature valid: True
Tampered signature valid: False
Shared secret hex values will differ on each run because the keys are generated anew.
Conclusion
Post-quantum cryptography is no longer theoretical. The APIs are here, NIST has standardized the algorithms, and .NET 10 gives you a clean, idiomatic way to start using them. The question isn't whether to adopt PQC, it's when. Starting now gives you time to build expertise before it becomes urgent.
Thanks for stopping by!
We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.
If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!
Questions? Comments? Just want to say hi? Leave a comment below and let's start a conversation.