Introduction
This post focuses on a topic critical to help move to post-quantum cryptographic (PQC) algorithms and that topic is crypto-agility for applications that use cryptography.
This post does not discuss the implications of quantum computers on cryptography; you can read that elsewhere such as:
What is Crypto-Agility?
As NIST explains, crypto-agility is about changing cryptographic algorithms without interrupting the flow of a running system, which is exactly why it is foundational to long-term resilience in the PQC era.
However, crypto-agility is not simply the ability to swap one algorithm for another; it is the engineering discipline of designing protocols, applications, services, and infrastructure so cryptographic mechanisms can be updated repeatedly, safely, and with minimal disruption. In practical terms, that means avoiding hard-coded algorithm choices, understanding where cryptography is used, and building abstraction, configuration, and testing into systems so transitions—such as the move to post-quantum cryptography—do not become expensive, brittle, one-time rewrites.
Note: This post assumes you know how to build a correct cryptographic system, if you do not, you should not, rather you should seek help from cryptographic professionals.
The Need for Crypto-Agility NOW
The industry has known for at least 15 years that crypto-agility is a solid engineering practice, but sadly, few products support it for their own crypto. The looming threat from PQC makes crypto-agility not just a good idea, but an important forcing function – everyone designing and developing applications must move to use crypto-agility to help migrate to PQC and beyond.
The main threat posed by quantum computers is the potential to break asymmetric algorithms like RSA, Elliptic Curve and Diffie-Hellman, these algorithms are used extensively to:
- Wrap, exchange, or agree-upon symmetric encryption keys that are then used to bulk encrypt data
- Sign data and code
- Authenticate principals
We will focus primarily on the first bullet as this is an endemic problem today and it is the core of the Harvest Now, Decrypt Later (HNDL) threat.
What Makes This Post Different
There are many articles about the importance of crypto-agility, but few focus on HOW to achieve crypto-agility, and that is where this article is different, we will focus on real-world designs and coding mechanics.
Step 0 – Don’t Do Your Own Crypto Agility!
Think about the shared responsibility model, if you use Infrastructure as a Service (IaaS) you need to do more work, but you have more control.
If you use Platform as a Service (PaaS) then PaaS pushes more of the responsibility to the provider, rather than you.
The same applies to cryptography.
Where possible, you should offload tasks like signing and encryption to a service or product you trust has a PQC plan to do the crypto work for you. For example, rather than writing your own code to encrypt backups, use a backup service that performs that task (or has plans to do so) and allows you to control the key wrapping keys.
However, if you must use your own crypto, then read on!
Crypto-Agile Designs
Many applications that protect data with encryption do so without regard for the cryptography used. For example, plaintext is passed to encryption code and is then encrypted with a key, or a key derived from some key material, and then the code writes out the ciphertext.
Something like this:
using System.Security.Cryptography;
using var aes = Aes.Create();
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(args[1]));
byte[] ciphertext = aes.EncryptEcb(
await File.ReadAllBytesAsync(args[0]), PaddingMode.Zeros);
await File.WriteAllBytesAsync(args[0] + ".enc", ciphertext);
This code is problematic on many fronts, including:
- Encryption algorithm details are hard-coded in the core code path. This makes crypto agility impossible.
- The crypto is weak:
- Using Electronic Code Book (ECB) block cipher mode
- No data authentication
- Not using an appropriate Key Derivation Function (KDF).
- You might also say there is no Initialization Vector (IV) but an IV is not used by ECB.
- The ciphertext is just a blob of bytes, there is no metadata to indicate how this blob was created and what cryptographic parameters are used.
Hard-coded algorithm assumptions are a strong anti-pattern for crypto-agility and usually make safe migration to newer algorithms harder or more expensive.
To be crypto-agile, a system must support new ciphersuite metadata and store that metadata with the ciphertext; there are several ways to do this.
Crypto-agility in Microsoft Products
To get a better understanding of how to create crypto-agile solutions, let’s look at how Microsoft has addressed this in some products, notably:
- Microsoft Office
- SQL Server and Azure SQL DB
- Azure Blob Storage
There is no single crypto-agility pattern that fits every application’s needs for agility, but these should give you some ideas.
Microsoft Office
In its Open Office XML (OOXML) file structure, Microsoft Office embeds every aspect of the crypto used to protect a document as metadata in the XML payload. Microsoft Office refers to this structure as Agile Encryption. OOXML is primarily a symmetric key solution, so that is all we will cover here.
I have some C# code that dumps the main crypto metadata from an encrypted OOXML file, you can find it here and the output looks a little like this:
Key Data:
saltSize : 16
blockSize : 16
keyBits : 256
hashSize : 64
cipherAlgorithm : AES
cipherChaining : ChainingModeCBC
hashAlgorithm : SHA512
saltValue (or IV) : ktwu3iMe57cJ0LTDJHwf7g==
Password Encryptor:
spinCount : 100000
saltSize : 16
blockSize : 16
keyBits : 256
hashSize : 64
cipherAlgorithm : AES
cipherChaining : ChainingModeCBC
hashAlgorithm : SHA512
saltValue (or IV) : p8MRLOBj/a5MOYG1tvfA/Q==
encryptedVerifierHashInput : D8jQbWvjyPK8uWa1Z/0hiw==
encryptedVerifierHashValue : ikHKt4eXn<snip>QsfCKVqQaawWe1celUw==
encryptedKeyValue : p0lVCnKVd<snip>KrmggpYbeH69p8MBvahw=
This is enough data to create the various cryptographic objects needed to decrypt this document.
Depending on the language and crypto libraries you use, you could use code like this pseudo code to decrypt data given a block of crypto-metadata:
descriptor = parse(encryption_metadata)
cipherName = descriptor.cipherAlgorithm
chainMode = descriptor.cipherChaining
paddingMode = descriptor.padding
keyBits = descriptor.keyBits
blockSize = descriptor.blockSize
hashName = descriptor.hashAlgorithm
hashSize = descriptor.hashSize
spinCount = descriptor.spinCount
IV = decodeBase64(descriptor.saltValue)
cipher = GetAlgFromAlgName(cipherName)
hash = GetAlgFromAlgName(hashName) )
if cipher is null: fail("unsupported cipher")
if hash is null: fail("unsupported hash")
cipher.build(keyBits, chainMode, blockSize, paddingMode)
plaintext = cipher.decrypt(ciphertext, IV, key)
function GetAlgFromAlgName (name):
switch name:
case "AES": return new Aes()
case "TripleDES": return new TripleDes()
case "SHA256": return new Sha256()
case "SHA384": return new Sha384()
case "SHA512": return new Sha512()
default: return null
This pattern works well for documents because even though the metadata is large, it’s probably smaller than the document. But it does not work so well with rows of data, such as database platforms.
So, let’s look at that.
SQL Server and Azure SQL DB
SQL Server and Azure SQL DB offer a feature called Always Encrypted (AE) that also supports crypto agility, but rather than store metadata, it has a version number at the start of the ciphertext that maps to set of cryptographic primitives.
Currently, SQL Server only has one version of AE, version 1, but if the SQL team added a new set of cryptographic primitives, then the version number would be bumped to 2.
You can see this version number if you dump the hex-encoded ciphertext, below is an example.
Look at the first byte of each NationalIDNumber, it’s always 0x01, that’s the version number, and that maps the following ciphersuite:
AEAD_AES_256_CBC_HMAC_SHA_256
Which, when translated to English means:
- AEAD — Authenticated Encryption with Associated Data: Provides both encryption (confidentiality) + authentication (integrity + authenticity) in one step. It can also protect "associated data" (e.g., metadata) that isn't encrypted.
- AES_256 — Advanced Encryption Standard with a 256-bit key (strong symmetric encryption).
- CBC — Cipher Block Chaining mode (handles padding and chaining blocks; requires an Initialization Vector/IV).
- HMAC_SHA_256 — Hash-based Message Authentication Code (MAC) using SHA-256 (provides the authentication tag/MAC).
This is an Encrypt-then-MAC construction: encrypt the data first, then compute a MAC over the ciphertext (plus associated plaintext data) to authenticate it.
If you look at the .NET SqlClient driver source code, you can see the version number is held in the AlgorithmVersion member variable, and the ciphersuite details are gated on that version number. When a new version is introduced, the code is updated to support it while continuing to read version 1 data and writing new data with the latest version, such as version 2.
Azure Blob Storage Client-side Encryption
The Azure Blob Storage client library for .NET supports encrypting data within client applications before uploading to Azure Storage, and decrypting data while downloading to the client. The library also supports integration with Azure Key Vault for key management.
At the time of writing this, there are two ciphersuite versions supported by the client library. Version 2 came into being because of a Padding Oracle vulnerability (read more here) in v1, rendering v1 obsolete.
Blob Storage client-side encryption supports various key wrapping mechanisms, you can learn more here.
You can see how this code is managed in a crypto-agile manner by looking at the Azure SDK source code.
Post Quantum Crypto and Version Numbering
Imagine over the years, you started with a version 1 ciphersuite, then in 2020 you added a newer version 2, and finally, today with Q-Day around the corner, you want to add a version 3 that is quantum safe.
This is the pattern you might use using version numbers:
public enum CryptoVersion : byte
{
V1 = 1,
V2 = 2,
V3 = 3,
Latest = V3,
}
public static byte[] Encrypt(
ReadOnlySpan<byte> plaintext,
ReadOnlySpan<char> password,
CryptoVersion version = CryptoVersion.Latest)
{
switch (version)
{
case CryptoVersion.V1:
return EncryptV1(plaintext, password);
case CryptoVersion.V2:
case CryptoVersion.V3:
return EncryptGcm(plaintext, password, version);
default:
throw new ArgumentOutOfRangeException(
nameof(version),
version,
"Unknown crypto version.");
}
}
public static byte[] Decrypt(ReadOnlySpan<byte> blob,
ReadOnlySpan<char> password)
{
CryptoBlobHeader header = ParseHeader(blob);
return header.Version switch
{
CryptoVersion.V1 => DecryptV1(blob, password, header),
CryptoVersion.V2 => DecryptGcm(blob, password, header),
CryptoVersion.V3 => DecryptGcm(blob, password, header),
_ => throw new CryptographicException(
$"Unsupported blob version {(byte)header.Version}."),
};
}
This code is simple; when encrypting you pass in (or generate) appropriate cryptographic parameters and then an Encrypt method that then passes it off the correct encryption handler that varies by ciphersuite.
For decrypt, the only parameter you need is the password to derive the key encryption key (KEK) that decrypts the embedded wrapped data encryption key (DEK) and the rest is read from the header metadata that is written as part of the encryption operation.
Over time, if you add more ciphersuite versions, you just bump up the version number and call the Encrypt or Decrypt handler function. Easy! I have a version of this here.
In a future installment, I will add a v4 that also digitally signs the ciphertext blob using ML-DSA, but for the moment, V3 is quantum-safe because it uses quantum-safe symmetric algorithms and key sizes.
I have left out setting the block size, key size, IV, nonce, padding mode, block cipher mode and so on, to keep the code smaller. Store all IVs, salts, and nonces alongside the ciphertext as metadata, and ensure their integrity and authenticity by including them as associated data in an AEAD (Authenticated Encryption with Associated Data) scheme. They are not encrypted, but because they are authenticated by the AEAD construction, any tampering is detected
When your code reads encrypted data, it reads the version number and decrypts the data using the correct set of algorithms and configuration. When your code writes ciphertext it always uses the latest supported version. So, you could read a v1 blob, edit the data and then upon write, use v3 algorithms and settings.
The ciphertext blob has a 4-byte ‘magic’ header, in this case ‘CA42’ for Crypto Agility 42. The ‘42’ is there for no specific reason. Your ‘magic’ header, if you decide to use one, is totally up to you!
The ciphertext blob looks like this on disk across the various example versions:
Versions 2 and 3 are functionally identical; the only difference is the AES key size, visible in the wrapped DEK blocks; v3 increases the symmetric key from 128-bit to 256-bit, raising the effective security margin against Grover’s algorithm.
Sample code can be found here.
JOSE and Crypto Agility
Another option, especially for greenfield solutions, is to use JOSE.
JOSE (JSON Object Signing and Encryption) provides a strong foundation for crypto agility through its explicit separation of key wrapping (alg) and content encryption (enc) algorithm identifiers in the JWE protected header.
Because algorithm selection is declarative and self-describing, migrating between cipher suites requires no structural changes to the payload format, only a header update and corresponding key material rotation.
This makes JOSE well suited to versioned algorithm tiers, where each version maps cleanly to a distinct alg/enc pairing and older versions can be deprecated without breaking the envelope format.
Looking ahead, the IETF JOSE working group is actively developing algorithm registrations for post-quantum primitives - particularly ML-KEM for key encapsulation and ML-DSA for signatures - meaning the same JWE envelope structure will carry forward into a post-quantum world once those identifiers are standardized, with no changes required to parsing or storage infrastructure. Until then, implementations targeting PQC can use private x-prefixed header parameters as a forward-compatibility shim, accepting the loss of interoperability as a temporary trade-off.
Here's a highly abridged JOSE serialization example using the x- shim for ML-KEM and ML-DSA:
{
// Key wrapping:
// ML-KEM-1024 (via x- private header) + HKDF-SHA-512 → AES-256-KW
// Content encryption: AES-256-GCM
// Signing: ML-DSA-87 (JWS wrapper — see outer envelope)
"protected": {
// Standard JOSE fields
"enc": "A256GCM", // content encryption algorithm
"alg": "A256KW", // key wrapping algorithm (KEK → DEK)
"kid": "key-2024-v3", // key identifier
"cty": "application/json", // content type
// PQC shim — private parameters (x- prefix = non-registered, bespoke)
"x-kem-alg": "ML-KEM-1024", // FIPS 203; encapsulate the KEK
"x-kem-kid": "mlkem-pub-001", // recipient ML-KEM public key id
"x-kdf": "HKDF-SHA-512", // derives AES-256-KW KEK from ML-KEM shared secret
"x-kdf-info": "v3-kek-derive", // HKDF info string (context binding)
"x-sig-alg": "ML-DSA-87", // FIPS 204; signs outer JWS envelope
}
CMS and Crypto Agility
CMS is especially relevant for PQC migration because ML-KEM use in CMS is now standardized using _KEMRecipientInfo_, allowing post-quantum key establishment to be incorporated without changing the overall CMS envelope grammar. You can read more here.
CMS and JOSE solve similar problems, but they use different serialization models. CMS is defined with ASN.1 and is usually encoded as a compact binary structure (commonly DER, though BER is also used in some contexts), much like X.509-related formats. JOSE, by contrast, uses JSON-based serializations, often with base64url-encoded components in JWS/JWE, which usually makes it easier for developers to inspect and debug but can increase message size relative to a compact binary encoding.
DER (Distinguished Encoding Rules) and BER (Basic Encoding Rules) are simply encoding rules, DER is stricter than BER.
The Fly in the Ointment – Existing code
So far, we have spoken about creating new systems that use crypto agility. But what about systems we already have in place that simply create a blob of ciphertext data and nothing to describe its crypto settings?
You really have two options.
The first is to use one of the patterns above to wrap the ciphertext blob and call that version 1 or describe all the parameters
The second is to add a distinct header at the start of new ciphertext and include the metadata, and for old data (let’s call it v1) if that header is missing, then it’s old ciphertext with no metadata.
Think of the header as a magic number like that used in a JPEG (0xFF 0xD8 0xFF), GIF89a (GIF89a), PNG (0x89 PNG\r\n\1x1a\n) or PDF (%PDF-) file. You could use something like 0x00 0xC1 PH 3R 0x01, which is a null followed by ‘CIPHER’ and then the version number, 0x01. Of course, there is a small, but non-zero chance that some ciphertext could include this series of magic values, so make sure you code gracefully fails when it does not decrypt legacy data.
Summary
To be honest, this post is about 8 pages longer than I wanted, but I would rather err on the side of completeness. Besides, as JRR Tolkien said of The Lord of the Rings, “This tale grew in the telling.”
Crypto-agility is the engineering capability to change cryptographic primitives safely and repeatedly without redesigning systems, and it is now essential for post-quantum migration.
Crypto-agility requires either self-describing cryptographic metadata or versioned ciphertext formats so implementations can read legacy data while writing with the newest approved algorithms. A well-designed crypto-agile system should aim to read older ciphertext formats long enough to support migration, while writing new data with the newest approved configuration.
Practical patterns range from Office-style embedded metadata, to SQL-style and Azure Blob Storage version numbers, to JOSE/CMS headers and metadata that separate key wrapping from content encryption.
The design rule is simple: remove hard-coded algorithm assumptions, persist enough information to reconstruct the cryptographic context, and build systems so algorithm upgrades become routine engineering rather than emergency rewrites.
Thanks
I would like to thank the following people for their valuable input and review of this post:
- Jack Richins – Azure Security, PQC
- Jessica Krynitsky – Windows Security
- Andrei Popov – Windows Security
- Pieter Vanhove – Azure Data
- Jeremy Barton – .NET Crypto
- Raul Garcia – Microsoft Crypto Board