Cryptography in .NET spans two layers that solve different problems and are easy to reach for at the wrong time: the general-purpose System.Security.Cryptography namespace, and the higher-level ASP.NET Core Data Protection API built on top of it. This guide covers both, plus the pieces most teams get subtly wrong β key storage across a web farm, password hashing parameters, nonce handling for authenticated encryption, and the post-quantum algorithms .NET 10 shipped. It assumes you are comfortable with C# and ASP.NET Core and want to know exactly which API to reach for, not a cryptography theory primer; where a version-specific detail matters, such as a default that changed or an API that is now obsolete, it is called out explicitly rather than assumed.
What .NET Gives You for Cryptography#
System.Security.Cryptography exposes algorithm-specific types rather than one generic "encrypt this" API: Aes/AesGcm for symmetric encryption, RSA and ECDsa for asymmetric signing, SHA256/SHA512 and friends for hashing, and Rfc2898DeriveBytes for password-based key derivation. Each type is a thin, algorithm-agnostic wrapper backed by a platform cryptography library β OpenSSL on Linux, CNG on Windows, Apple's CryptoKit on macOS and iOS β so the same C# call compiles to whichever native, hardware-accelerated implementation the OS provides. ASP.NET Core's Data Protection API sits a level above this: it is not a general-purpose cryptography toolkit for interoperating with other systems, but a purpose-built mechanism for protecting data that your own app will read back later, such as an authentication cookie or a password-reset token. Reach for Data Protection first for that narrower job; reach for the algorithm-specific types when you need a specific, standard format that another system also has to produce or verify.
How .NET's Cryptography APIs Are Structured#
Most algorithm types follow the same shape: a static Create() factory method (or, for asymmetric algorithms, Create(keySize)) returns a platform-backed implementation, so your code never calls a constructor directly or depends on which OS library is underneath it. Symmetric and asymmetric algorithms both implement IDisposable, because the underlying native handles hold key material that should be cleared promptly rather than left for the garbage collector. The post-quantum types introduced in .NET 10 β covered later in this guide β deliberately break from this pattern: MLKem, MLDsa and SlhDsa do not derive from AsymmetricAlgorithm at all, and use static GenerateKey/ImportFromPem-style factory methods instead, because the classic "create an empty object, then import or generate a key into it" shape did not fit these algorithms' key formats cleanly.
Getting Started: Protecting Data Your App Will Read Back#
For the common case β a token, a cookie payload, anything your own app encrypts and later decrypts β register Data Protection and inject IDataProtectionProvider rather than reaching for a raw algorithm:
builder.Services.AddDataProtection()
.SetApplicationName("contoso-invoices");public sealed class PasswordResetTokenService(IDataProtectionProvider provider)
{
// The purpose string scopes this protector so tokens issued for one
// feature can never be replayed against a different one, even though
// both ultimately use the same underlying key ring.
private readonly IDataProtector _protector =
provider.CreateProtector("PasswordResetTokenService.v1");
public string Protect(string userId) => _protector.Protect(userId);
public string? TryUnprotect(string token)
{
try
{
return _protector.Unprotect(token);
}
catch (CryptographicException)
{
return null; // tampered, expired, or created with a different purpose
}
}
}Unprotect throws CryptographicException for anything that fails authentication β a tampered payload, a token from a different purpose string, or one signed by a key that has since been revoked β so callers should always treat that exception as "reject this input," never as a bug to fix by catching a broader exception type.
ASP.NET Core Data Protection: The Key Ring#
Every IDataProtector is backed by a key ring: a set of keys, each with an activation and expiration date, that Data Protection manages automatically. New requests to protect data always use the newest active key; unprotecting data works with any key in the ring that is not yet retired, which is what lets a token issued yesterday still validate today even after a new key activates. By default, a key's lifetime is 90 days, after which Data Protection generates a replacement automatically:
builder.Services.AddDataProtection()
.SetApplicationName("contoso-invoices")
.SetDefaultKeyLifetime(TimeSpan.FromDays(30));Shortening the lifetime reduces how much data a single compromised key can decrypt; lengthening it reduces key churn for data that needs to remain readable for a long time, such as a "remember me" cookie. Whichever value you choose, keys themselves should be encrypted at rest β on Windows, Data Protection uses DPAPI automatically when it can; elsewhere, or when centralizing key storage, protect them explicitly, as shown next.
Key Storage in Web Farms and Containers#
The default key storage location is local to a single machine, which quietly breaks the moment an app runs on more than one instance: an authentication cookie encrypted by instance A cannot be decrypted by instance B, because B has never seen A's keys. Every horizontally scaled ASP.NET Core app needs a shared key store and, usually, a shared application name so all instances agree on the same discriminator:
builder.Services.AddDataProtection()
.SetApplicationName("contoso-invoices")
.PersistKeysToAzureBlobStorage(blobUri, new DefaultAzureCredential())
.ProtectKeysWithAzureKeyVault(keyVaultKeyUri, new DefaultAzureCredential());PersistKeysToAzureBlobStorage gives every instance a shared, durable location for the key ring itself; ProtectKeysWithAzureKeyVault additionally encrypts those keys at rest using a key you manage in Key Vault, rather than relying on machine-local DPAPI, which does not exist on Linux containers. Equivalent options exist for other topologies: PersistKeysToFileSystem against a shared network path, PersistKeysToDbContext to store keys through EF Core, and PersistKeysToStackExchangeRedis for a Redis-backed cache tier many web farms already run. Whichever store you pick, SetApplicationName still matters β without it, Data Protection derives the discriminator from the content root path, which is often identical across container instances built from the same image, but can differ across environments in ways that silently split the key ring. In a Docker container specifically, remember that the container's filesystem is ephemeral by default: PersistKeysToFileSystem pointed at a path inside the container loses every key on restart unless that path is backed by a mounted volume or one of the shared stores above.
Hashing and Password Storage#
Never hash passwords with a general-purpose digest like SHA-256 directly β it is fast by design, which is exactly the wrong property for something an attacker wants to brute-force offline. ASP.NET Core Identity's PasswordHasher<TUser> exists so you do not have to make these decisions yourself:
var hasher = new PasswordHasher<ApplicationUser>();
string hashed = hasher.HashPassword(user, "P@ssw0rd!");
var result = hasher.VerifyHashedPassword(user, hashed, suppliedPassword);
if (result == PasswordVerificationResult.SuccessRehashNeeded)
{
// The stored hash used an older scheme or iteration count; rehash with
// the current settings now that the plaintext password is in hand.
user.PasswordHash = hasher.HashPassword(user, suppliedPassword);
await userManager.UpdateAsync(user);
}In its default IdentityV3 compatibility mode, PasswordHasher derives the stored hash with PBKDF2 using HMAC-SHA512, a 128-bit salt and a 256-bit derived subkey, at 100,000 iterations by default β configurable through PasswordHasherOptions.IterationCount if you want to raise it as hardware gets faster:
builder.Services.Configure<PasswordHasherOptions>(options =>
{
options.IterationCount = 150_000; // benchmark against your login latency budget
});SuccessRehashNeeded is what makes raising the iteration count safe to roll out gradually: existing hashes keep verifying against the old parameters, and each one is silently upgraded to the new settings the next time that user signs in. For password derivation outside of Identity β deriving an encryption key from a passphrase, for example, rather than storing a verifier β use the static one-shot method directly instead of hand-rolling the loop:
byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(
password: Encoding.UTF8.GetBytes(passphrase),
salt: salt,
iterations: 210_000,
hashAlgorithm: HashAlgorithmName.SHA256,
outputLength: 32);Symmetric Encryption with AesGcm#
AesGcm is an AEAD (authenticated encryption with associated data) cipher: a single call produces both ciphertext and an authentication tag, so tampering is detected on decryption instead of silently producing corrupted plaintext, without composing a separate encrypt-then-MAC scheme yourself.
public static byte[] Encrypt(byte[] key, byte[] plaintext, out byte[] nonce, out byte[] tag)
{
nonce = new byte[12]; // the standard AES-GCM nonce length
tag = new byte[AesGcm.TagByteSizes.MaxSize];
var ciphertext = new byte[plaintext.Length];
RandomNumberGenerator.Fill(nonce);
using var aesGcm = new AesGcm(key, AesGcm.TagByteSizes.MaxSize);
aesGcm.Encrypt(nonce, plaintext, ciphertext, tag);
return ciphertext;
}The single detail that matters most with GCM: never reuse a nonce with the same key. Unlike a predictable IV in CBC mode, which is merely a weakness, a reused GCM nonce can let an attacker recover the authentication key outright and forge future messages. Generate the nonce fresh from RandomNumberGenerator for every encryption, as above, or use a strictly incrementing counter if you can guarantee it never repeats across restarts and replicas β a random 96-bit nonce is the safer default for most application code.
Asymmetric Cryptography: RSA and ECDsa Signatures#
Use asymmetric signatures when a value needs to be verifiable by a party that does not share a secret with you β a JWT another service validates, a webhook payload, a software update manifest. ECDsa is the better default for new signing code: smaller keys and faster operations than RSA at an equivalent security level.
using ECDsa signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] signature = signingKey.SignData(data, HashAlgorithmName.SHA256);
bool isValid = signingKey.VerifyData(data, signature, HashAlgorithmName.SHA256);Reach for RSA instead when interoperating with a system that only supports it, or when you need encryption as well as signing (ECDsa signs but does not encrypt; RSA can do both, though ECDH is the asymmetric-encryption analogue on the elliptic-curve side):
using RSA rsa = RSA.Create(3072);
byte[] signature = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
bool isValid = rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);Prefer RSASignaturePadding.Pss over the older Pkcs1 padding for new RSA signatures, since PSS has a stronger security proof. When a signing key comes from an X.509 certificate rather than a bare key pair, pull the private key off the certificate rather than managing it separately:
using var certificate = new X509Certificate2(
"signing.pfx", pfxPassword, X509KeyStorageFlags.EphemeralKeySet);
using RSA? certKey = certificate.GetRSAPrivateKey();
byte[] signature = certKey!.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);X509KeyStorageFlags.EphemeralKeySet keeps the private key in process memory only, rather than persisting it to a per-user or per-machine key store on disk β the right default for a server process that should not leave key material behind after it exits. Never disable certificate chain validation (ServerCertificateCustomValidationCallback returning true unconditionally) to work around a development certificate problem and then ship that code β it silently accepts any certificate, valid or not, for the lifetime of the client.
Secure Randomness#
System.Random is a fast, deterministic pseudo-random generator with a small internal state β perfect for shuffling a deck of cards in a game, unsuitable for anything a security decision depends on, because its output is predictable once enough of it has been observed. Use RandomNumberGenerator for tokens, keys, nonces and salts instead:
// Wrong: predictable, and never intended for security-sensitive values.
var insecureToken = Random.Shared.Next().ToString();
// Right: backed by the OS's cryptographically secure random generator.
byte[] tokenBytes = RandomNumberGenerator.GetBytes(32);
string urlSafeToken = Convert.ToBase64String(tokenBytes);
// GetInt32 avoids the modulo bias a naive `GetBytes(...) % range` has.
int oneTimeCode = RandomNumberGenerator.GetInt32(100_000, 1_000_000);Post-Quantum Cryptography in .NET 10#
.NET 10 adds support for three NIST-standardized post-quantum algorithms: ML-KEM (FIPS 203, key encapsulation), ML-DSA (FIPS 204, signatures) and SLH-DSA (FIPS 205, signatures), as the new MLKem, MLDsa and SlhDsa types in System.Security.Cryptography. These exist because a sufficiently capable quantum computer would break the math behind RSA and elliptic-curve cryptography β not a near-term concern for data you need confidential for a few months, but a real one for anything an adversary could record today and decrypt once such a machine exists, sometimes called "harvest now, decrypt later."
// ML-KEM establishes a shared secret; it is a key-encapsulation mechanism,
// not a signature scheme, so it plays the same role RSA/ECDH play in a
// classical key-exchange handshake.
using MLKem kemKey = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);
string publicKeyPem = kemKey.ExportSubjectPublicKeyInfoPem();
// ML-DSA is the post-quantum signature counterpart to RSA/ECDsa.
using MLDsa signingKey = MLDsa.ImportFromPem(privateKeyPem);
byte[] signature = signingKey.SignData(data);
bool isValid = signingKey.VerifyData(data, signature);Check IsSupported before using any of them, since availability depends on the underlying platform crypto library β OpenSSL 3.5 or newer on Linux and macOS, or Windows CNG with PQC support:
if (!MLKem.IsSupported)
{
// Fall back to a classical key-exchange path on platforms without a
// PQC-capable OpenSSL or CNG build.
}MLDsa, SlhDsa and the hybrid CompositeMLDsa type are marked [Experimental] under diagnostic SYSLIB5006 while the underlying standards and .NET's own API surface for them are still settling β MLKem itself is not experimental overall, though a handful of its members still are. CompositeMLDsa is worth knowing about even before you need pure PQC: it signs with a classical algorithm and ML-DSA together, so the signature stays valid as long as either algorithm remains unbroken, which is a practical migration path for systems that cannot yet commit fully to post-quantum-only signing.
var algorithm = CompositeMLDsaAlgorithm.MLDsa65WithRSA4096Pss;
using CompositeMLDsa privateKey = CompositeMLDsa.GenerateKey(algorithm);
byte[] signature = privateKey.SignData(data);For most line-of-business apps, there is no urgent need to rewrite TLS or token-signing infrastructure around these types today. The practical action is narrower: identify data or signatures that must stay trustworthy for years, and start tracking your dependencies' and partners' PQC readiness now, since the migration itself β new certificate formats, larger keys and signatures, updated protocol support β will take longer than the cryptographic transition alone.
Common Pitfalls#
- Hashing passwords with SHA-256/SHA-512 directly instead of
PasswordHasherorRfc2898DeriveBytes.Pbkdf2β a fast general-purpose hash is the opposite of what password storage needs. - Reusing an AES-GCM nonce with the same key, which is a far more severe failure than a repeated CBC IV and can expose the authentication key itself.
- Hardcoding an encryption key or IV as a constant in source code, which makes the ciphertext trivially reversible by anyone who can read the assembly.
- Storing Data Protection keys on local disk for a horizontally scaled app and being surprised when users get logged out at random as requests hit different instances.
- Disabling certificate validation in a callback "temporarily" for a local development certificate, then shipping that code unchanged to production.
- Treating
System.Randomas good enough for a token or reset code because it "looks random enough" in testing.
Best Practices#
- Use ASP.NET Core Data Protection for anything your own app both writes and later reads; reserve raw algorithm types for interoperability with an external format or system.
- Set
SetApplicationNameexplicitly and pick a shared key store the moment an app runs on more than one instance, not after the first mysterious sign-out bug report. - Let
PasswordHasher'sSuccessRehashNeededresult drive iteration-count upgrades gradually, rather than forcing a password reset for every user at once. - Generate AES-GCM nonces from
RandomNumberGeneratorfor every message; never derive them predictably from a counter you cannot guarantee is unique. - Prefer
ECDsafor new signing code unless you specifically need RSA's broader interoperability or its encryption capability. - Track
IsSupportedand treat the post-quantum types as forward-looking infrastructure, not yet a wholesale replacement for classical algorithms in most applications.
Choosing the Right Primitive#
| Goal | Recommended API | Why |
|---|---|---|
| Protect a token or cookie your own app will read back later | ASP.NET Core Data Protection (IDataProtector) | Key rotation, expiry and purpose isolation are handled for you |
| Store a user's password | PasswordHasher<TUser> (ASP.NET Core Identity) | PBKDF2-HMAC-SHA512 with a per-password salt and automatic rehash-on-upgrade |
| Encrypt a blob with a key you manage yourself | AesGcm | Authenticated encryption in one call; tampering is detected, not silently accepted |
| Prove data was signed by you, verifiable by others | ECDsa (default) or RSA (for interoperability) | Smaller, faster keys with ECDsa; broader ecosystem support with RSA |
| Derive a key from a passphrase | Rfc2898DeriveBytes.Pbkdf2 | Explicit iteration count and hash algorithm, no hand-rolled loop |
| Protect data that must stay confidential for decades | Classical algorithm plus CompositeMLDsa/MLKem hybrid | Stays secure even if only one of the two algorithms is eventually broken |
Frequently Asked Questions#
What is the difference between ASP.NET Core Data Protection and raw cryptography APIs?#
Data Protection is a higher-level system for protecting data your own application generates and later consumes β it manages key generation, rotation and storage for you through IDataProtector. The algorithm-specific types in System.Security.Cryptography, such as AesGcm or RSA, are lower-level building blocks for cases where you need a specific, standard wire format, often because another system has to produce or verify the same data.
Why does ASP.NET Core Identity use PBKDF2 instead of a memory-hard algorithm like Argon2?#
PBKDF2 is what PasswordHasher implements today, tuned with a configurable iteration count and SHA-512 as the pseudorandom function in its default compatibility mode; it remains an accepted, standards-based choice for password storage when the iteration count is set high enough for current hardware. If you need a memory-hard alternative, you would bring in a third-party library rather than the built-in Identity hasher, and would then own rehash-on-upgrade logic yourself.
Is it safe to reuse the same AES-GCM key across many messages?#
Yes, as long as every message uses a fresh, non-repeating nonce with that key. The danger is reusing the nonce, not the key: a repeated nonce-key pair can expose the authentication key and let an attacker forge messages, which is why generating the nonce from RandomNumberGenerator for every call matters more than rotating the encryption key itself.
Do I need to start using ML-KEM and ML-DSA now?#
For most applications, no β they are new in .NET 10, and several of the related types are still marked [Experimental]. Start paying attention if you handle data that must remain confidential for a decade or more, since a hybrid approach with CompositeMLDsa is a reasonable way to gain forward protection without abandoning classical algorithms your partners and infrastructure already support.
How do I store Data Protection keys correctly for an app running in Kubernetes?#
Treat it the same as any other multi-instance deployment: pick a shared store such as PersistKeysToAzureBlobStorage, PersistKeysToDbContext or PersistKeysToStackExchangeRedis rather than the local filesystem, set SetApplicationName explicitly so every pod agrees on the same key-ring discriminator, and protect the keys at rest with ProtectKeysWithAzureKeyVault if you are on Azure. See Secrets Management in .NET for how workload identity supplies the credential this needs without storing one in the cluster.
Summary#
- ASP.NET Core Data Protection and
System.Security.Cryptography's algorithm types solve different problems β pick Data Protection first for data only your own app needs to read back. - The Data Protection key ring defaults to a 90-day key lifetime and must use a shared store plus
SetApplicationNamethe moment an app runs on more than one instance. PasswordHasher's default scheme is PBKDF2-HMAC-SHA512 with a 128-bit salt and 100,000 iterations;SuccessRehashNeededmakes raising that count a gradual, safe rollout.AesGcmgives you authenticated encryption in one call, but reusing a nonce with the same key is a severe, not merely theoretical, failure.ECDsais the better default for new signing code;RSAremains the right call for interoperability or when you also need encryption.- .NET 10 ships
MLKem,MLDsaandSlhDsafor post-quantum cryptography, withCompositeMLDsaas a hybrid bridge for data that must stay confidential far into the future.