Data Protection in .NET: Master Encryption & Key Management Easily

Bulletproof Encryption in .NET: Best Practices & Examples

Secure your .NET apps with AES‑GCM, RSA‑OAEP and hybrid crypto. Step‑by‑step C# code, safe key storage, rotation tips and pitfalls.

.NET Development·By amarozka · October 15, 2025

Bulletproof Encryption in .NET: Best Practices & Examples

Are you sure your AES code would pass a pen test tomorrow? I keep seeing apps that roll crypto “by memory”, reuse nonces, or stash keys in appsettings.json. Let’s fix that today with clean, copy‑paste friendly C# you can drop into your project.

Quick crypto toolbox in .NET

Use these building blocks:

  • AesGcm – modern AEAD; gives confidentiality + integrity (the auth tag). 12‑byte nonce, 16‑byte tag.
  • RSA – use OAEP‑SHA256 for wrapping small keys, not big payloads.
  • ECDsa – fast signatures (P‑256 or P‑384). Use SHA‑256/384.
  • ECDiffieHellman – derive a shared secret for session keys.
  • RandomNumberGenerator – cryptographic random bytes. Never use Random for keys/nonces.
  • ProtectedData (Windows DPAPI) or ASP.NET Core Data Protection for local key storage; cloud KMS for prod.

Golden rules

  • Don’t invent formats. Don’t use ECB. Don’t re‑use nonces/IVs on the same key.
  • Prefer AEAD (AES‑GCM) so every ciphertext is authenticated.
  • Generate keys and nonces with RandomNumberGenerator.
  • Keep keys out of source control and config. Use KMS/Key Vault, or wrap at rest.
  • Version your keys with a kid and log it with every operation.
  • When you must take a password, stretch it (PBKDF2/Argon2) and store a salt.

AES‑GCM: small payloads (strings, JSON, records)

AES‑GCM is the default choice for most app data. Here’s a tiny utility you can reuse.

using System;
using System.Security.Cryptography;
using System.Text;

public static class AesGcmHelper
{
    public const int NonceSize = 12;   // 96 bits
    public const int TagSize = 16;     // 128 bits

    public static byte[] NewKey(int size = 32) => RandomNumberGenerator.GetBytes(size); // 32 = 256‑bit key

    public static (byte[] Ciphertext, byte[] Nonce, byte[] Tag) Encrypt(
        ReadOnlySpan<byte> key,
        ReadOnlySpan<byte> plaintext,
        ReadOnlySpan<byte> aad = default)
    {
        byte[] nonce = RandomNumberGenerator.GetBytes(NonceSize);
        byte[] ciphertext = new byte[plaintext.Length];
        byte[] tag = new byte[TagSize];
        using var gcm = new AesGcm(key);
        gcm.Encrypt(nonce, plaintext, ciphertext, tag, aad);
        return (ciphertext, nonce, tag);
    }

    public static byte[] Decrypt(
        ReadOnlySpan<byte> key,
        ReadOnlySpan<byte> nonce,
        ReadOnlySpan<byte> tag,
        ReadOnlySpan<byte> ciphertext,
        ReadOnlySpan<byte> aad = default)
    {
        byte[] plaintext = new byte[ciphertext.Length];
        using var gcm = new AesGcm(key);
        gcm.Decrypt(nonce, ciphertext, tag, plaintext, aad); // throws CryptographicException if tampered
        return plaintext;
    }

    public static (string Enc, string Nonce, string Tag) EncryptToBase64(byte[] key, string text, string? aad = null)
    {
        var plain = Encoding.UTF8.GetBytes(text);
        var aadBytes = aad is null ? ReadOnlySpan<byte>.Empty : Encoding.UTF8.GetBytes(aad);
        var (c, n, t) = Encrypt(key, plain, aadBytes);
        return (Convert.ToBase64String(c), Convert.ToBase64String(n), Convert.ToBase64String(t));
    }

    public static string DecryptFromBase64(byte[] key, string encB64, string nonceB64, string tagB64, string? aad = null)
    {
        var c = Convert.FromBase64String(encB64);
        var n = Convert.FromBase64String(nonceB64);
        var t = Convert.FromBase64String(tagB64);
        var aadBytes = aad is null ? ReadOnlySpan<byte>.Empty : Encoding.UTF8.GetBytes(aad);
        var plain = Decrypt(key, n, t, c, aadBytes);
        return Encoding.UTF8.GetString(plain);
    }
}

Usage

var key = AesGcmHelper.NewKey();
var (enc, nonce, tag) = AesGcmHelper.Encrypt(key, Encoding.UTF8.GetBytes("secret payload"));
var recovered = AesGcmHelper.Decrypt(key, nonce, tag, enc);

A quick check list

  • New random nonce per message.
  • Keep the tag. If you lose it, decryption can’t verify.
  • Use AAD (associated data) for things you don’t want encrypted but want bound (e.g., row id, version).

Password‑based encryption (PBE) with PBKDF2

If a user gives you a password (not a key), derive a key with PBKDF2 and a random salt. Store salt and iteration count next to the ciphertext. Bump iterations over time.

using System.Security.Cryptography;

public static class Pbe
{
    public static (byte[] Key, byte[] Salt, int Iterations) FromPassword(
        string password, int keySize = 32, int iterations = 200_000)
    {
        byte[] salt = RandomNumberGenerator.GetBytes(16);
        using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
        return (pbkdf2.GetBytes(keySize), salt, iterations);
    }

    public static byte[] FromPasswordWithSalt(string password, byte[] salt, int iterations, int keySize = 32)
    {
        using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
        return pbkdf2.GetBytes(keySize);
    }
}

Storage tip: write a small header: {"kdf":"PBKDF2-SHA256","it":200000,"salt":"…"} in Base64, then your AES‑GCM payload.

Hybrid encryption: AES for data, RSA for the AES key

You never encrypt big data with RSA. You encrypt a small random AES key (the content encryption key, CEK) with RSA, and you encrypt the data with AES‑GCM. Then you ship an envelope.

[Plaintext] --AES-GCM(CEK, Nonce)--> [Ciphertext + Tag]
         CEK --RSA-OAEP(SHA-256, Receiver Public Key)--> [WrappedKey]

Envelope = {
  alg: "A256GCM-RSA-OAEP-256",
  kid: "rsa-key-2025-10",
  nonce, tag, ciphertext, wrappedKey, aad (all Base64)
}

A compact envelope in C#

using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public sealed class CryptoEnvelope
{
    public string Alg { get; init; } = "A256GCM-RSA-OAEP-256";
    public string Kid { get; init; } = string.Empty; // key id of the RSA public key used
    public string Nonce { get; init; } = string.Empty; // Base64
    public string Tag { get; init; } = string.Empty;   // Base64
    public string Ciphertext { get; init; } = string.Empty; // Base64
    public string WrappedKey { get; init; } = string.Empty;  // Base64
    public string? Aad { get; init; } // optional Base64
}

public static class HybridCrypto
{
    public static CryptoEnvelope EncryptWithRsa(string rsaPublicPem, byte[] data, string kid, byte[]? aad = null)
    {
        byte[] cek = RandomNumberGenerator.GetBytes(32);
        var (c, n, t) = AesGcmHelper.Encrypt(cek, data, aad ?? ReadOnlySpan<byte>.Empty);

        using RSA rsa = RSA.Create();
        rsa.ImportFromPem(rsaPublicPem);
        byte[] wrapped = rsa.Encrypt(cek, RSAEncryptionPadding.OaepSHA256);

        return new CryptoEnvelope
        {
            Kid = kid,
            Nonce = Convert.ToBase64String(n),
            Tag = Convert.ToBase64String(t),
            Ciphertext = Convert.ToBase64String(c),
            WrappedKey = Convert.ToBase64String(wrapped),
            Aad = aad is null ? null : Convert.ToBase64String(aad)
        };
    }

    public static byte[] DecryptWithRsa(string rsaPrivatePem, CryptoEnvelope env)
    {
        using RSA rsa = RSA.Create();
        rsa.ImportFromPem(rsaPrivatePem);
        byte[] cek = rsa.Decrypt(Convert.FromBase64String(env.WrappedKey), RSAEncryptionPadding.OaepSHA256);

        var nonce = Convert.FromBase64String(env.Nonce);
        var tag = Convert.FromBase64String(env.Tag);
        var c = Convert.FromBase64String(env.Ciphertext);
        var aad = env.Aad is null ? ReadOnlySpan<byte>.Empty : Convert.FromBase64String(env.Aad);
        return AesGcmHelper.Decrypt(cek, nonce, tag, c, aad);
    }
}

Key notes

  • Use RSA 3072 or 4096 today. Use OAEP with SHA‑256.
  • Keep kid on the envelope to support rotation. Store a mapping kid -> RSA private.
  • Bind useful AAD (for example tenant id, record id) so a swapped envelope can’t pass checks.

Generating keys

using var rsa = RSA.Create(3072);
var privPkcs8 = rsa.ExportPkcs8PrivateKey();
var pubSpki = rsa.ExportSubjectPublicKeyInfo();
string privPem = PemEncoding.Write("PRIVATE KEY", privPkcs8);
string pubPem  = PemEncoding.Write("PUBLIC KEY", pubSpki);

Chunked AES‑GCM for large files

AesGcm works on a buffer, but you can encrypt big files by splitting into frames under the same CEK with unique nonces per frame. A simple format:

File:
[magic 4 bytes] [version 1 byte]
[baseNonce 12 bytes]
repeat frames {
  [counter 4 bytes] [len 4 bytes] [nonce 12 bytes] [tag 16 bytes] [ciphertext len bytes]
}

Nonce per frame = baseNonce with last 4 bytes replaced by the counter. Never repeat counters for a given file.

using System.Buffers.Binary;
using System.IO;
using System.Security.Cryptography;

public static class GcmFile
{
    private static readonly byte[] Magic = new byte[]{(byte)'G',(byte)'C',(byte)'M',(byte)'1'};

    public static void EncryptStream(Stream input, Stream output, ReadOnlySpan<byte> key, int frameSize = 64 * 1024)
    {
        Span<byte> baseNonce = stackalloc byte[AesGcmHelper.NonceSize];
        RandomNumberGenerator.Fill(baseNonce);

        // header
        output.Write(Magic, 0, Magic.Length);
        output.WriteByte(1);
        output.Write(baseNonce);

        using var gcm = new AesGcm(key);
        byte[] plain = new byte[frameSize];
        int read;
        uint counter = 0;
        while ((read = input.Read(plain, 0, plain.Length)) > 0)
        {
            Span<byte> nonce = stackalloc byte[AesGcmHelper.NonceSize];
            baseNonce.CopyTo(nonce);
            BinaryPrimitives.WriteUInt32BigEndian(nonce[^4..], counter);

            byte[] cipher = new byte[read];
            byte[] tag = new byte[AesGcmHelper.TagSize];
            gcm.Encrypt(nonce, plain.AsSpan(0, read), cipher, tag);

            Span<byte> hdr = stackalloc byte[8];
            BinaryPrimitives.WriteUInt32BigEndian(hdr, counter);
            BinaryPrimitives.WriteUInt32BigEndian(hdr[4..], (uint)read);
            output.Write(hdr);
            output.Write(nonce);
            output.Write(tag);
            output.Write(cipher);

            counter++;
        }
    }
}

Why this works: AEAD binds each frame. Unique nonces keep security strong. You may add a file‑level AAD (e.g., file name, hash) to each Encrypt call.

Digital signatures with ECDSA (P‑256)

Use signatures to prove “who wrote this” and to protect change tracking.

using System.Security.Cryptography;

public static class Signatures
{
    public static (string PublicPem, string PrivatePem) NewKeyPair()
    {
        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var priv = ecdsa.ExportPkcs8PrivateKey();
        var pub  = ecdsa.ExportSubjectPublicKeyInfo();
        return (
            PemEncoding.Write("PUBLIC KEY", pub),
            PemEncoding.Write("PRIVATE KEY", priv)
        );
    }

    public static byte[] Sign(string privatePem, byte[] data)
    {
        using var ecdsa = ECDsa.Create();
        ecdsa.ImportFromPem(privatePem);
        return ecdsa.SignData(data, HashAlgorithmName.SHA256);
    }

    public static bool Verify(string publicPem, byte[] data, byte[] signature)
    {
        using var ecdsa = ECDsa.Create();
        ecdsa.ImportFromPem(publicPem);
        return ecdsa.VerifyData(data, signature, HashAlgorithmName.SHA256);
    }
}

Use cases: sign JWT‑like tokens (outside of this post), release manifests, or any audit trail event.

Storing and loading keys safely

Local developer box

  • Windows: protect blobs with DPAPI.
using System.Security.Cryptography;

byte[] protectedBlob = ProtectedData.Protect(
    userData: keyBytes,
    optionalEntropy: null,
    scope: DataProtectionScope.CurrentUser);

byte[] unprotected = ProtectedData.Unprotect(protectedBlob, null, DataProtectionScope.CurrentUser);
  • ASP.NET Core Data Protection: persist keys to disk and protect with a cert (Linux/macOS) or DPAPI (Windows).
// Program.cs
using Microsoft.AspNetCore.DataProtection;
using System.Security.Cryptography.X509Certificates;

var builder = WebApplication.CreateBuilder(args);
var cert = new X509Certificate2("/certs/dataprotection.pfx", "p@ssw0rd");

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo("/var/app-keys"))
    .ProtectKeysWithCertificate(cert);

Production

  • Let Azure Key Vault / AWS KMS / GCP KMS hold your RSA private keys, or use an HSM. Wrap CEKs there.
  • Avoid plain files with private keys on app hosts. If you must, encrypt them at rest and load into memory only when needed.
  • Rotate keys on a schedule. Keep old privates to unwrap legacy data until rewrapped.

Key rotation with kid

A simple rotation model:

  1. Generate a new RSA key pair. Assign a new kid like rsa-key-2025-10.
  2. Publish the public key only.
  3. Start writing new envelopes with the new kid.
  4. Background task: rewrap old CEKs from old kid to new kid (no need to touch plaintext).
  5. Retire the old private after all data is rewrapped or past retention.

Logging the kid with each encrypt/decrypt call makes support painless.

Common mistakes I still see

  • Reusing an IV/nonce under the same AES key.
  • Using RSA to encrypt files directly (slow, risky, size‑limited).
  • Skipping the auth tag (CBC without MAC). Use AEAD or encrypt‑then‑MAC.
  • Storing keys in config files, or inside container images.
  • Using Random for secrets (it’s not for crypto).
  • Low PBKDF2 iterations like 10k from old blog posts.
  • Forgetting to validate inputs when Base64‑decoding or parsing envelopes.

Testing yourself quickly

  • Flip any byte in the ciphertext: decryption must throw.
  • Flip any byte in tag: decryption must throw.
  • Swap kid to a non‑existing one: unwrap must fail.
  • Try reusing a nonce on purpose in a test; add a guard to detect duplicates if you cache nonces.

Putting it all together: end‑to‑end sample

Here’s a short flow that ties things together for a JSON record.

// 1) Generate RSA keys once (admin tool), store private in KMS, publish public
var (pubPem, privPem) = ("", "");
using (var rsa = RSA.Create(3072))
{
    var priv = rsa.ExportPkcs8PrivateKey();
    var pub = rsa.ExportSubjectPublicKeyInfo();
    pubPem = PemEncoding.Write("PUBLIC KEY", pub);
    privPem = PemEncoding.Write("PRIVATE KEY", priv);
}

// 2) Producer encrypts a record
string kid = "rsa-key-2025-10";
var data = Encoding.UTF8.GetBytes("{\"id\":42,\"balance\":123.45}");
var aad = Encoding.UTF8.GetBytes("tenant:acme-co|type:ledger");
var env = HybridCrypto.EncryptWithRsa(pubPem, data, kid, aad);
string json = JsonSerializer.Serialize(env);

// 3) Consumer decrypts
var back = HybridCrypto.DecryptWithRsa(privPem, JsonSerializer.Deserialize<CryptoEnvelope>(json)!);
Console.WriteLine(Encoding.UTF8.GetString(back));

That’s a complete hybrid flow you can adapt for messages, files, or database fields.

Recommendations you can apply today

  • Switch sensitive fields to AES‑GCM. Keep nonce+tag next to ciphertext.
  • Add kid to every envelope and start logging it.
  • Move private keys into KMS or protect them with DPAPI/Data Protection.
  • For passwords, adopt PBKDF2 (≥200k) with random 16‑byte salts and store both.
  • Add tests that flip bytes and expect failures.

FAQ: quick answers for busy developers

AES‑GCM or AES‑CBC?

Use AES‑GCM unless you have a strict legacy need. If you must use CBC, add an HMAC (encrypt‑then‑MAC) and derive separate keys for enc/mac.

Which RSA key size?

3072 is a good default today. 4096 if policy requires. Always OAEP with SHA‑256.

Is ECDSA safe?

Yes, with P‑256/P‑384 and SHA‑256/384. Keep the private key offline or in KMS.

How many PBKDF2 iterations?

Start around 200k on server‑side .NET. Measure on your hardware and raise over time.

Where do I store CEKs?

Don’t. Generate per message/file and wrap them with your RSA public key. Keep only wrapped CEKs with the envelope.

Can I reuse a nonce across different keys?

Nonce uniqueness is required per key. Rotate keys often and avoid any reuse under the same key.

What about Argon2 or scrypt?

Great options for passwords. If you need them, use a vetted library. PBKDF2 is built‑in and fine when configured well.

Conclusion: stronger crypto with sane defaults

Good crypto in .NET is not magic. With AES‑GCM for data, RSA‑OAEP for small secrets, and clear key handling, you can ship safe features fast. Try the envelope code, add kid logging, and tell me what else you want covered next.

What part gives you the most pain today – key rotation, secrets storage, or signatures? Drop a comment and let’s sort it out.

Leave a Reply

Your email address will not be published. Required fields are marked *