← All modules
Symmetric-key·36 min

Modes of operation & AEAD: ECB, CBC, CTR, GCM & ChaCha20-Poly1305

A block cipher encrypts 16 bytes. Everything else (long messages, randomness, tamper detection, even keeping a card number a card number) comes from the mode wrapped around it, and that's where most real-world breaks happen.

View as slides: 15 visual slides, ready to present
Developer / EngineerSecurity ArchitectResearcher / AcademicIT Ops / DevOps
Authenticated: GCM, ChaCha20-Poly1305, CCM, SIVConfidentiality plus integrityConfidentiality only: CBC, CTR, CFB, OFB, XTSRandomized, but malleableWeak: ECBDeterministic, leaks equal blocks
AES encrypts 16 bytes. Everything else comes from the mode around it, and that's where most real breaks happenOpen the slides →

What a mode adds to a block cipher

AES is a keyed permutation on 128-bit blocks: give it a key and exactly 16 bytes, and it returns 16 bytes that look random to anyone without the key. That's all it does. It can't take a 3-byte message or a 3-gigabyte one, and it's deterministic, so the same block under the same key always comes out the same. A mode of operation is the recipe that turns this one-block primitive into a scheme for real messages, and the AES module introduced the main ones. This module goes further: what each mode actually guarantees, how each one fails, and why modern systems have settled on authenticated encryption (AEAD).

The yardstick for confidentiality is indistinguishability under chosen-plaintext attack (IND-CPA): even an attacker who can get messages of their choice encrypted shouldn't be able to tell which of two equal-length messages a ciphertext came from. Any deterministic scheme fails this immediately, because the attacker can encrypt both candidates and compare. So every secure mode needs something that changes from message to message: a random IV, a nonce, or a counter. Most of the failures in this module come down to that value being missing, predictable, or repeated.

Confidentiality is also not the whole job. None of ECB, CBC, or CTR stops an attacker from changing the ciphertext, and in each of them a change produces a predictable change in the plaintext. Protecting integrity takes a MAC (covered in the MACs module) or, better, a mode that builds one in.

Three generations of modes

Confidentiality only, weak: ECB

No IV at all. Deterministic block by block, so equal plaintext blocks show up as equal ciphertext blocks.

Confidentiality only: CBC, CTR (and CFB, OFB, XTS)

Randomized by an IV or nonce, so IND-CPA secure when used correctly. Malleable: an attacker can change the plaintext without the key and nothing notices.

Authenticated encryption: GCM, ChaCha20-Poly1305, CCM, SIV

Confidentiality plus integrity of the ciphertext and of associated header data, in one primitive with one key. Decryption either returns the exact plaintext or fails.

ECB and the penguin

Electronic Codebook mode encrypts each 16-byte block independently with the same key. It's a literal codebook: one fixed ciphertext for each possible plaintext block. The textbook illustration encrypts a bitmap of a penguin. Every run of identical pixels becomes a run of identical ciphertext blocks, so the outline survives encryption, and anyone can see what was 'hidden' without knowing the key. The classic version uses Tux, the Linux mascot. The one below is an original penguin, drawn from colored shapes when the page loads and encrypted with real AES in your browser.

Look at the block counts under each image. The plaintext has only a small fraction of distinct 16-byte blocks, because most of it is flat sky, flat black and flat white. ECB has exactly the same number: it maps blocks one to one, so it preserves every equality in the input. CBC and CTR produce as many distinct blocks as there are blocks, and the picture turns into noise. Each time you press the button, the key changes and the ECB colors change with it, but the outline doesn't. The shape is coming from the mode, not the key.

Images make the leak visible, but it's the same leak everywhere: a database column encrypted with ECB shows which rows hold equal values, and a structured record shows which fields repeat. The AES module also covers the worse problem, byte-at-a-time decryption when an attacker can put chosen bytes next to a secret. ECB is acceptable for exactly one job: encrypting a single block of random data, such as a key. Even there, a dedicated key-wrap mode like AES-KW (RFC 3394) is the better choice, because it also detects tampering.

The same picture under three AES modes, encrypted for real in your browser

Plaintext…
AES-ECB…
AES-CBC…
AES-CTR…

Each image is 160×160 RGBA pixels, 4 bytes each, so one 16-byte AES block is exactly four pixels side by side. The ciphertext bytes are drawn as pixels with the alpha byte set back to opaque. All three modes use Web Crypto. It has no ECB mode on purpose, so ECB here is AES-CBC on one block at a time with an all-zero IV, which is the same thing as raw AES on that block.

Ci=EK(Pi)⟹Pi=Pj  ⟺  Ci=CjC_i = E_K(P_i) \quad\Longrightarrow\quad P_i = P_j \iff C_i = C_j

ECB is a function of each block alone, so equality between plaintext blocks is visible in the ciphertext.

NIST SP 800-38A, appendix F.1.1, in Node
import { createCipheriv } from "node:crypto";

const key = Buffer.from("2b7e151628aed2a6abf7158809cf4f3c", "hex");
const block = Buffer.from("6bc1bee22e409f96e93d7e117393172a", "hex");

const ecb = createCipheriv("aes-128-ecb", key, null).setAutoPadding(false);
console.log(Buffer.concat([ecb.update(Buffer.concat([block, block])), ecb.final()]).toString("hex"));
// 3ad77bb40d7a3660a89ecaf32466ef97 3ad77bb40d7a3660a89ecaf32466ef97   (spaces added)

The same plaintext block twice gives the same ciphertext block twice. The first block matches the NIST vector and OpenSSL ('openssl enc -aes-128-ecb -nopad'). Web Crypto doesn't offer ECB at all, which is a deliberate design choice.

Practice

The demo image is 160 × 160 RGBA pixels at 4 bytes per pixel. How many 16-byte AES blocks does it take?

CBC: chaining, IVs and padding

Cipher Block Chaining XORs each plaintext block into the previous ciphertext block before encrypting, starting from an initialization vector. Two identical messages under the same key now encrypt differently as long as the IVs differ, and identical blocks inside one message encrypt differently because their predecessors differ. Encryption is strictly sequential. Decryption isn't: each Pᵢ needs only Cᵢ and Cᵢ₋₁, so it can run in parallel.

CBC needs more from its IV than uniqueness. SP 800-38A requires it to be unpredictable, because an attacker who knows the next IV in advance can choose a plaintext block that cancels it and test a guess about an earlier block. That's exactly what the BEAST attack (Duong and Rizzo, 2011) did to TLS 1.0, which used the last ciphertext block of one record as the IV of the next. TLS 1.1 fixed it with an explicit random IV per record.

CBC only processes whole blocks, so the plaintext is padded first. PKCS#7 padding appends n bytes each of value n, where n is between 1 and 16. A message that already fills its last block gets a whole extra block of 0x10 bytes, so the receiver can always remove the padding without guessing. That small rule becomes an attack surface: if a receiver behaves differently when padding is invalid, it leaks information about the plaintext (see the padding-oracle section at the end).

Worked example, from SP 800-38A appendix F.2.1 and checked with Node and OpenSSL: key 2b7e1516 28aed2a6 abf71588 09cf4f3c, IV 00010203 04050607 08090a0b 0c0d0e0f, and plaintext block 6bc1bee2 2e409f96 e93d7e11 7393172a encrypt to 7649abac 8119b246 cee98e9b 12e9197d. Encrypting that same block twice in a row gives a different second block, 4cbbc858 756b3581 25529e96 98a38f44, where ECB would repeat the first.

Ci=EK(Pi⊕Ci−1),C0=IVPi=DK(Ci)⊕Ci−1C_i = E_K(P_i \oplus C_{i-1}), \quad C_0 = \mathrm{IV} \qquad\qquad P_i = D_K(C_i) \oplus C_{i-1}

Practice

A 32-byte plaintext is encrypted with AES-CBC and PKCS#7 padding. How many bytes of ciphertext come out, not counting the IV?

CTR: a block cipher as a stream cipher

Counter mode never puts the plaintext through AES at all. It encrypts a sequence of counter blocks, a nonce followed by a block number, and XORs the result (the keystream) into the plaintext. There's no padding, since the last keystream block is just cut to length. Encryption and decryption are the same operation, any block can be processed on its own, so the work parallelizes perfectly, and only the AES encrypt direction is ever used.

CTR's one rule is that no counter block may ever repeat under a key, across all messages. Repeat one and the same keystream is XORed into two plaintexts. XOR the two ciphertexts together and the keystream cancels, leaving P₁ ⊕ P₂: the 'two-time pad', which usually gives up both messages to anyone who can guess part of one. With AES-128 in Node, the plaintexts 'ATTACK AT DAWN!!' and 'RETREAT AT DUSK!' under the same key and counter encrypt to ciphertexts starting ad d8 … and be c9 …, and the XOR of the two ciphertexts is exactly the XOR of the two plaintexts.

CTR is also the most malleable mode. Flipping bit j of the ciphertext flips bit j of the plaintext and nothing else, with no garbled block to give it away. An attacker who knows that a message says 'PAY 100' at some offset can make it say 'PAY 900' without the key. CTR alone is never enough; it needs a MAC, and GCM is CTR with one built in.

Worked example, from SP 800-38A appendix F.5.1 and checked with Node and OpenSSL: the same key and plaintext block as above, with initial counter block f0f1f2f3 f4f5f6f7 f8f9fafb fcfdfeff, give 874d6191 b620e326 1bef6864 990db6ce.

Ci=Pi⊕EK(nonce ∥ i)⟹Ci⊕Ci′=Pi⊕Pi′ if the nonce repeatsC_i = P_i \oplus E_K(\mathrm{nonce} \,\|\, i) \qquad\Longrightarrow\qquad C_i \oplus C'_i = P_i \oplus P'_i \text{ if the nonce repeats}

Practice

Two messages were encrypted with AES-CTR under the same key and the same nonce. The first ciphertext byte of message 1 is 0xad and of message 2 is 0xbe. Message 1 starts with 'A' (0x41). What letter does message 2 start with?

CFB, OFB and XTS

SP 800-38A defines two more feedback modes. Cipher Feedback (CFB) encrypts the previous ciphertext block and XORs the result into the next plaintext block, which makes it a self-synchronizing stream cipher: after a lost or corrupted block it recovers by itself. OpenPGP uses a variant of CFB. Output Feedback (OFB) keeps encrypting its own output to make a keystream that doesn't depend on the message at all, so it behaves like CTR but can't be parallelized or randomly accessed. Neither has an advantage over CTR in new designs, and both are just as malleable.

Disk encryption has a different problem. A sector must encrypt to a ciphertext of exactly the same size, there's no room to store an IV or a tag, and any sector must be readable on its own. XTS (IEEE 1619, approved by NIST as SP 800-38E for storage devices) handles this with a tweak: the sector number and the block's position within the sector change how each block is encrypted, so identical data in different places encrypts differently, without storing anything extra. XTS-AES is what BitLocker, FileVault 2, and Linux dm-crypt/LUKS use by default.

XTS still isn't authenticated, and it can't be: with no extra space there's nowhere to put a tag. Within one sector it also works much like ECB, so an attacker who sees a disk image at two points in time can tell which 16-byte blocks changed, and can roll a sector back to an older version without detection. That's acceptable for a lost laptop, which is the threat XTS is designed for, and not enough for storage an attacker can watch or write to.

The confidentiality-only modes at a glance

ECB

No IV. Leaks equal blocks. Only for a single block of random data.

CBC

Unpredictable IV, PKCS#7 padding. Sequential encryption, parallel decryption. Legacy; must be paired with a MAC (encrypt-then-MAC).

CTR

Unique nonce/counter. No padding, fully parallel, random access. The base of GCM and CCM.

CFB / OFB

Feedback stream modes from 2001. Fine but not better than CTR; found in OpenPGP and older protocols.

XTS

Tweaked by sector and position, length-preserving, no IV stored. Full-disk encryption only.

Format-preserving encryption: FF1, and what happened to FF3

Every mode so far turns bytes into bytes, and usually more of them. That's a problem for a database column declared as 16 digits, or a 9-digit SSN field validated by a dozen downstream systems. Format-preserving encryption (FPE) encrypts a string of n numerals in some radix to another string of n numerals in the same radix: a card number to another 16 digits, a lowercase identifier to another of the same length. NIST standardizes it in SP 800-38G as another mode of operation of AES.

FF1, the mode NIST still stands behind, is a 10-round Feistel network over numerals. It splits the input into halves A and B, and each round computes an AES-CBC-MAC of the parameters, a tweak, the round number, and B, turns the result into an integer, and adds it to A modulo radix^m. Modular addition instead of XOR is what keeps every intermediate value inside the alphabet. The tweak is a public value, such as the visible first six and last four digits of a card, that changes the permutation without changing the key.

FF3 was standardized next to FF1 in 2016 and didn't last. In 2017 Durak and Vaudenay broke it over small domains with a slide attack that exploits weak domain separation in how FF3 mixes its tweak into the rounds. NIST's 2019 draft revision replaced it with FF3-1, which cut the tweak from 64 to 56 bits, and raised the minimum domain for both modes from 100 to one million values. In 2021 Beyne published practical linear-cryptanalysis attacks on FF3-1 over small domains as well. NIST's second public draft of SP 800-38G Rev. 1, in February 2025, specifies FF1 only: FF3 is gone, and FF3-1 with it. The same draft disallows floating-point arithmetic in FF1 implementations and requires a larger FF1 domain. Rev. 1 was still a draft when this section was written, so check NIST's CSRC page for its status, but the direction is clear: don't build anything new on FF3 or FF3-1.

FPE gives up properties the AEAD modes below provide. It's deterministic, so equal inputs give equal outputs; that's what lets a tokenized column still be joined and searched, and it also leaks equality and allows dictionary attacks when the domain is small. There's no tag, so any well-formed string decrypts to something. FPE is a tool for fitting encryption into fields that can't change shape, not a general-purpose replacement for GCM.

c=(NUMradix(A)+yi) mod radixm,yi=NUM(CBC-MACK(P ∥ T ∥ … ∥ i ∥ B))c = \bigl(\mathrm{NUM}_{radix}(A) + y_i\bigr) \bmod radix^{m}, \qquad y_i = \mathrm{NUM}\bigl(\mathrm{CBC\text{-}MAC}_K(P \,\|\, T \,\|\, \ldots \,\|\, i \,\|\, B)\bigr)

One FF1 round. The new half is A plus a keyed function of B, modulo the size of the half's domain; then the halves swap.

NIST FF1 Sample 1 (AES-128, radix 10, empty tweak)
key        2B7E1516 28AED2A6 ABF71588 09CF4F3C
plaintext  0123456789
ciphertext 2433477484

Same length, same alphabet. The FF1 Playground tool reproduces this and the other NIST samples in your browser, and shows all ten rounds.

Practice

A tokenization system keeps the first 6 and last 4 digits of a 16-digit card number and FF1-encrypts only the digits in between. How many different values can the encrypted part take?

Why encryption needs integrity

All of the modes so far are malleable, and an attacker who can change ciphertext can often turn that into reading it. CBC bit-flipping lets them rewrite chosen bytes of the next block. A padding oracle decrypts CBC ciphertexts completely, a byte at a time, using nothing but the receiver's 'bad padding' error. CTR lets them flip any plaintext bit they like. In every case the receiver decrypts attacker-modified data and acts on it. The fix is to make the receiver refuse anything it didn't receive exactly as sent.

Combining a cipher and a MAC yourself can be done in three orders. Encrypt-and-MAC sends the ciphertext plus a MAC of the plaintext (SSH's original design). MAC-then-encrypt MACs the plaintext and encrypts both (TLS up to 1.2 with CBC suites). Encrypt-then-MAC encrypts, then MACs the ciphertext (IPsec ESP). Bellare and Namprempre showed in 2000 that only encrypt-then-MAC is secure in general for any secure cipher and MAC. The other two can be made safe in particular combinations, but MAC-then-encrypt is exactly where TLS's padding-oracle attacks lived: the receiver had to decrypt and check padding before it could check the MAC.

Authenticated encryption with associated data (AEAD) packages the right answer as one primitive. Its interface, standardized in RFC 5116, takes a key, a nonce, the plaintext, and associated data: header bytes that must be authenticated but stay readable, such as a packet's addresses or a record's type and length. It returns ciphertext with a tag. Decryption takes all of that back and either returns exactly the original plaintext or fails, with no partial output. One key, one call, and no ordering choices left to get wrong.

Cipher + MAC, built by hand

  • •Two keys to derive and keep apart
  • •Three possible orders, and only encrypt-then-MAC is safe in general
  • •Easy to forget to cover the IV, header or lengths in the MAC
  • •Constant-time tag comparison is your job

AEAD

  • •One key and one nonce per message
  • •Ciphertext, nonce use and associated data all authenticated by design
  • •Decryption either returns the exact plaintext or fails
  • •The only kind of cipher suite TLS 1.3 allows
Seal(K,N,A,P)→C ∥ TOpen(K,N,A,C ∥ T)→P or ⊥\mathrm{Seal}(K, N, A, P) \to C \,\|\, T \qquad \mathrm{Open}(K, N, A, C \,\|\, T) \to P \text{ or } \bot

N is the nonce and A the associated data. ⊥ means 'reject': any change to N, A, C or T makes Open fail.

AES-GCM

Galois/Counter Mode (McGrew and Viega, standardized as NIST SP 800-38D in 2007) is CTR encryption plus the GHASH authenticator covered in the MACs module. The standard nonce is 96 bits. The 32-bit counter block J₀ = nonce ‖ 0x00000001 is reserved for masking the tag, and encryption starts at counter 2. GHASH runs over the associated data, then the ciphertext, then a block holding both lengths, and the tag is GHASH's output XORed with E_K(J₀). With AES-NI and carry-less multiply instructions, AES-GCM runs at several gigabytes per second per core, which is a large part of why it carries most TLS traffic.

GCM has hard limits that come from its structure. One message can be at most 2³² − 2 blocks (about 64 GiB), because the counter is 32 bits. With random 96-bit nonces, SP 800-38D caps a key at 2³² messages, to keep the chance of a random nonce collision negligible. TLS 1.3 avoids randomness altogether: each record's nonce is the record sequence number XORed into a per-connection IV, so nonces can't repeat within a connection, and the TLS 1.3 specification tells implementations to change keys before 2²⁴·⁵ records under AES-GCM.

A repeated nonce in GCM breaks it twice: the CTR keystream repeats (the two-time pad above), and the two tags give an attacker equations in the GHASH key H, which lets them forge messages under that key from then on (the MACs module has the details). Two less obvious properties: tags shorter than 128 bits weaken GCM faster than you'd expect, and GCM isn't key-committing, meaning one ciphertext can be built to decrypt correctly under two different keys. The 2018 'invisible salamanders' attack on Facebook Messenger's abuse reporting used exactly that.

Worked example, test case 2 from the GCM specification, checked with Node: an all-zero 128-bit key, an all-zero 96-bit nonce, no associated data, and one all-zero plaintext block give ciphertext 0388dace 60b6a392 f328c2b9 71b2fe78 and tag ab6e47d4 2cec13bd f53a67b2 1257bddf. Flip one bit of that ciphertext and decryption fails outright; Node throws 'Unsupported state or unable to authenticate data' and returns nothing.

AES-GCM with Web Crypto
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));        // 96-bit nonce: never reuse with this key
const aad = new TextEncoder().encode("record-type=1;len=11");  // authenticated, not encrypted

const sealed = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv, additionalData: aad, tagLength: 128 },
  key,
  new TextEncoder().encode("hello world"),
); // 11 bytes of ciphertext followed by the 16-byte tag

// Throws an OperationError if the iv, aad, ciphertext or tag was changed in any way.
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: aad }, key, sealed);

Web Crypto appends the tag to the ciphertext and checks it before releasing any plaintext. The AES-GCM Playground tool runs this in your browser.

Practice

GCM's per-message counter is 32 bits, and two counter values are unavailable for plaintext (one is never used, and J₀ masks the tag). What is the maximum number of 16-byte plaintext blocks in one GCM message?

ChaCha20-Poly1305

ChaCha20-Poly1305 (RFC 8439) is the AEAD built from Bernstein's ChaCha20 stream cipher and Poly1305 authenticator, both covered in their own modules. It takes a 256-bit key and a 96-bit nonce. ChaCha20 block 0 for that nonce supplies the one-time Poly1305 key, and encryption uses blocks 1, 2 and so on. Poly1305 then authenticates the associated data and the ciphertext, each zero-padded to a 16-byte boundary, followed by their two lengths as 64-bit little-endian numbers.

It's an encrypt-then-MAC design with the same nonce rule as GCM, and a nonce reuse is just as bad: the keystream repeats and the Poly1305 key repeats too. What it offers instead is speed without special hardware. ChaCha20 uses only additions, rotations and XORs on 32-bit words, so it runs in constant time on any CPU, while AES without AES instructions is either slow or, in the common table-based implementations, leaks timing through cache behavior. That's why TLS 1.3 clients on phones often prefer it, and why WireGuard uses it with no alternative cipher to negotiate.

Its 96-bit nonce is too short to pick at random for very large numbers of messages. XChaCha20-Poly1305 extends the nonce to 192 bits, so random nonces are safe for practically any volume. It's an IETF draft rather than an RFC, but libsodium has shipped it for years and it's a common default in application-level encryption libraries.

Worked example, RFC 8439 section 2.8.2, checked with Node's 'chacha20-poly1305' cipher: the 114-byte message beginning 'Ladies and Gentlemen of the class of '99' with the RFC's key, nonce 07000000 40414243 44454647 and 12 bytes of associated data gives ciphertext starting d31a8d34 648e60db 7b86afbc 53ef7ec2 and tag 1ae10b59 4f09e26a 7e902ecb d0600691.

Practice

ChaCha20 produces keystream in 64-byte blocks. How many ChaCha20 blocks does ChaCha20-Poly1305 compute to seal a 114-byte message, counting the one that makes the Poly1305 key?

CCM, SIV and nonce misuse

CCM (Counter with CBC-MAC; RFC 3610, NIST SP 800-38C) predates GCM. It computes a CBC-MAC over the nonce, lengths, associated data and plaintext, then encrypts the plaintext and the tag in CTR mode. That's MAC-then-encrypt, but in a specific form with a security proof, so the general warning doesn't apply. It needs only the AES encrypt direction and no field multiplication, which suits small hardware, and that's where it lives: WPA2's CCMP Wi-Fi encryption, Bluetooth Low Energy, Zigbee and IEEE 802.15.4, and as an optional TLS 1.3 suite for IoT. Its costs: two AES calls per block, and the message length has to be known before encryption starts, so it can't stream. The example in SP 800-38C appendix C encrypts 20212223 under key 40414243 … 4e4f with a 7-byte nonce to 7162015b, with the 4-byte tag 4dac255d (checked with Node).

Every AEAD so far fails badly if a nonce repeats. Nonce-misuse-resistant modes fail gracefully instead. SIV (Rogaway and Shrimpton, 2006; AES-SIV is RFC 5297) computes a MAC over the associated data and plaintext first, then uses that MAC as the IV for CTR encryption. If a nonce repeats, the only thing an attacker learns is whether the same plaintext was encrypted twice with the same associated data. Nothing else leaks and forgeries stay infeasible. AES-GCM-SIV (RFC 8452) applies the same idea with GCM's fast field arithmetic.

The price is two passes over the data, since the IV depends on the whole message, so SIV modes can't encrypt a stream as it arrives. They suit key wrapping, deterministic encryption of database fields where equality leaks are acceptable, and systems where nonce uniqueness can't be guaranteed: many servers sharing one key, or virtual machines restored from snapshots.

One more design worth knowing: OCB (RFC 7253) authenticates and encrypts in a single parallel pass with one AES call per block. Patents held it back for two decades; its designer abandoned them in 2021, and it remains less widely deployed than GCM.

The AEADs in deployment

AES-GCM

CTR + GHASH. 96-bit nonce, must be unique. Very fast with AES-NI/PCLMUL. TLS, IPsec, SSH, MACsec, cloud KMS envelopes.

ChaCha20-Poly1305

Stream cipher + one-time Poly1305. 96-bit nonce (192-bit with XChaCha20). Fast and constant-time in plain software. TLS, QUIC, WireGuard, SSH.

AES-CCM

CTR + CBC-MAC. Small hardware footprint, not streamable. WPA2, Bluetooth LE, Zigbee, constrained TLS.

AES-SIV / AES-GCM-SIV

MAC first, then encrypt with the MAC as IV. A repeated nonce leaks only exact repeats. Key wrapping, deterministic encryption, hard-to-coordinate nonces.

Choosing a mode and managing nonces

For new designs, use an AEAD from a maintained library, and treat every non-AEAD mode as legacy. AES-GCM is the default where AES hardware is available, which covers practically all servers and modern phones. ChaCha20-Poly1305 is the default where it might not be, and where constant-time behavior in software matters. Use AES-GCM-SIV or AES-SIV when you can't guarantee unique nonces. CCM is for when a standard or a radio chip requires it. XTS is only for disks. ECB and unauthenticated CBC or CTR belong only in compatibility code that's on its way out.

Most AEAD failures are nonce failures, so pick a nonce strategy deliberately. A counter that only ever goes up is safest when one party owns the key and can store state reliably, as in TLS's record sequence numbers. Random 96-bit nonces are simpler but limited to about 2³² messages per key for GCM; switch to XChaCha20's 192-bit nonces if you need more. Watch for anything that can roll state back, such as VM snapshots, restored backups, or two processes sharing a key and a counter.

Then check the details around the AEAD. Bind everything the receiver relies on into the associated data: protocol version, message type, sender and recipient, sequence numbers. Never use decrypted output before the tag check has passed; good APIs make that impossible, but streaming APIs sometimes release plaintext early. Rotate keys well before the per-key limits. And if one ciphertext might be opened under more than one key, as in multi-recipient or abuse-reporting systems, add key commitment, since GCM and ChaCha20-Poly1305 don't provide it.

Closing oracles in legacy ECB and CBC code

Two of the best-known attacks on block cipher modes never touch AES itself. Byte-at-a-time ECB decryption, covered in the AES module, works when a service encrypts attacker-chosen bytes next to a secret under ECB. The CBC padding oracle, walked through in 'Go deeper' below, works when a service decrypts attacker-modified ciphertext and lets them tell valid padding from invalid. Either way an ordinary endpoint becomes a decryption service, so the defense is to remove the oracle, not to strengthen the cipher.

The real fix for both is an AEAD. With AES-GCM or ChaCha20-Poly1305, a modified ciphertext fails the tag check before anything is decrypted or unpadded, and a fresh nonce per message means the same plaintext never encrypts to the same ciphertext twice, so there is nothing to build a dictionary from.

If legacy CBC has to stay for a while, wrap it in encrypt-then-MAC: compute HMAC over the IV and ciphertext, verify the tag in constant time, and only then decrypt and remove padding. MAC-then-encrypt, as in TLS's original CBC suites, has to decrypt and unpad before it can check the MAC, and that ordering is what Lucky Thirteen exploited through timing. Returning one generic error for every failure is necessary but not enough on its own: response time, log lines, connection resets and retry behavior can all still reveal which check failed. Rate limits slow an oracle down but don't close it.

ECB has no safe patch beyond retiring it. Treat any ECB use on more than a single block of random key material as a finding. And even with a sound mode, be careful about encrypting attacker-controlled input in the same message as a secret when the length can leak, for example after compression. That is the CRIME and BREACH family, which recovered cookies from compressed-then-encrypted HTTPS traffic.

Removes the oracle

  • •Migrate to an AEAD (AES-GCM, ChaCha20-Poly1305)
  • •Encrypt-then-MAC, tag checked before any decryption
  • •Constant-time tag comparison
  • •Retire ECB entirely

Only hides it

  • •A single generic error message
  • •Rate limiting or lockouts
  • •MAC-then-encrypt with careful unpadding
  • •Obscure or custom padding schemes
Go deeperA padding oracle, by hand⌄

This is Vaudenay's 2002 attack on CBC with PKCS#7 padding. The attacker has a ciphertext block Cᵢ and the block before it, Cᵢ₋₁, and can send modified ciphertexts to a server that reveals only whether the padding was valid. That might be through an error code, a different response, or a timing difference. Decryption computes Pᵢ = D_K(Cᵢ) ⊕ Cᵢ₋₁. The attacker can't compute D_K(Cᵢ), call it the intermediate value I, but controls Cᵢ₋₁ completely.

To find the last byte, they replace the last byte of Cᵢ₋₁ with each value g from 0x00 to 0xff and send the two blocks. For almost every g the padding is invalid. When it's valid, the last decrypted byte is most likely 0x01, so I[15] ⊕ g = 0x01 and I[15] = g ⊕ 0x01. XORing that with the real last byte of Cᵢ₋₁ gives the real plaintext byte. Then they set the last byte so it decrypts to 0x02, search the second-to-last byte for a decryption of 0x02, and so on through the block. At most 256 queries per byte, about 4,096 per block, and no key.

One complication: a valid response might also mean the block happens to end in 0x02 0x02, not 0x01. Changing the second-to-last byte and asking again tells the two cases apart. The general lesson is that an error message is an oracle. This attack has hit SSL 3.0 (POODLE, 2014), ASP.NET (2010), and TLS CBC suites through timing (Lucky Thirteen, 2013). Encrypt-then-MAC stops it, because the receiver rejects any modified ciphertext before looking at the padding. An AEAD stops it by never padding at all.

I=DK(Ci),I[15]⊕g=0x01  ⇒  Pi[15]=g⊕0x01⊕Ci−1[15]I = D_K(C_i), \qquad I[15] \oplus g = \texttt{0x01} \;\Rightarrow\; P_i[15] = g \oplus \texttt{0x01} \oplus C_{i-1}[15]

Practice

The real last byte of Cᵢ₋₁ is 0x3c. The server reports valid padding when the attacker sets that byte to g = 0x39 (and the follow-up check rules out a 0x02 0x02 ending). What is the last plaintext byte of Pᵢ, in hex?

Knowledge check

Test what you just learned →

12 quick questions, with an explanation for every answer.

Up next

RSA & public-key cryptography →

Two mathematically linked keys — one public, one private — solve the problem symmetric crypto can't: how do you share a secret with someone you've never met?

Questions & feedback

Discuss this page

Ask a question or comment with your GitHub account. New questions get a first answer from Claude, an AI model, clearly marked as AI; people can correct or add to it. How this works

Showing the discussion loads a widget from giscus.app and GitHub, which then see your IP address and browser details.