AES-GCM vs ChaCha20-Poly1305 is the choice between the two authenticated encryption algorithms that carry almost all encrypted traffic: TLS 1.3, QUIC, SSH, WireGuard, age and most application-level encryption. Both are secure when used correctly, both fail badly when a nonce repeats, and their speed depends heavily on the hardware.
At a glance
| AES-GCM | ChaCha20-Poly1305 | |
|---|---|---|
| Specification | NIST SP 800-38D (2007) | RFC 8439 (2018, replacing RFC 7539) |
| Key sizes | 128, 192, 256 bits | 256 bits |
| Nonce | 96 bits recommended | 96 bits (192 bits in XChaCha20) |
| Tag | 128 bits (shorter allowed, not recommended) | 128 bits |
| Max data per message | about 64 GiB (2³² − 2 blocks) | 256 GiB (2³² 64-byte blocks) |
| Random-nonce budget per key | 2³² messages (SP 800-38D) | Same order with 96-bit nonces; practically unlimited with XChaCha20 |
| Nonce reuse | Leaks XOR of plaintexts and the GHASH key, enabling forgeries | Leaks XOR of plaintexts and enables forgeries |
| Throughput, 16 KiB, OpenSSL 3.6.5 | 10.4 GB/s (AES-128), 8.8 GB/s (AES-256) | 2.1 GB/s |
| Throughput, 16-byte messages | 0.14 GB/s (AES-256) | 0.52 GB/s |
| Constant time without hardware | Hard (table-based AES leaks through caches) | Natural (add, rotate, XOR) |
| FIPS 140-3 approved | Yes | No |
| TLS 1.3 | TLS_AES_128_GCM_SHA256 (MUST), TLS_AES_256_GCM_SHA384 (SHOULD) | TLS_CHACHA20_POLY1305_SHA256 (SHOULD) |
| Other uses | JWE A128GCM and A256GCM, IPsec, SSH aes256-gcm@openssh.com, disk and cloud encryption | WireGuard, SSH chacha20-poly1305@openssh.com, age, libsodium |
Throughput measured with openssl speed -evp on one core of an Apple M4 Max, which has AES and polynomial-multiply instructions.
Performance depends on the CPU
AES-GCM is built for hardware: AES rounds run as single instructions (AES-NI on x86, the ARMv8 cryptography extensions) and GHASH uses carry-less multiplication. With both available it reaches about 10 GB/s per core here, about five times ChaCha20-Poly1305. Without them, AES must use lookup tables, which are slow and leak key bits through cache timing, or bitsliced code, which is constant time but slow. ChaCha20 needs only 32-bit additions, rotations and XORs, so it runs fast and in constant time on any CPU, including low-end phones and microcontrollers.
Small messages narrow or reverse the gap: with 16-byte inputs, ChaCha20-Poly1305 reached 0.52 GB/s against 0.14 GB/s for AES-256-GCM, because GCM's setup cost dominates. For typical TLS records (up to 16 KiB) on servers, AES-GCM is faster; for many tiny packets the difference is small either way.
Nonces and limits
Both are counter-mode ciphers with one-time authenticators, so the (key, nonce) pair must never repeat. A repeat reveals the XOR of the two plaintexts; with GCM it also lets an attacker recover the authentication subkey and forge messages under that key, and with Poly1305 it exposes the one-time key and allows forgery for that nonce. NIST SP 800-38D limits randomly generated 96-bit IVs to 2³² invocations per key to keep the collision probability negligible; TLS sidesteps the issue by deriving nonces from record sequence numbers and rekeying.
If your design generates random nonces for many messages under one key, use a construction with more nonce space or misuse resistance:
- XChaCha20-Poly1305 (192-bit nonces, described in an IRTF Internet-Draft and implemented by libsodium and others), where random nonces are safe for practically unlimited messages;
- AES-GCM-SIV (RFC 8452), where nonce reuse only reveals whether two messages were identical;
- or key derivation per message or per session, as TLS, age and HPKE do.
Neither algorithm commits to its key: a ciphertext can be crafted to decrypt validly under two different keys, which matters for multi-recipient or password-based designs (the "invisible salamanders" problem). Add a key commitment if attackers can choose keys.
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const plaintext = Buffer.from("card ending 4242, amount 19.99");
const aad = Buffer.from("order=7781");
for (const algorithm of ["aes-256-gcm", "chacha20-poly1305"]) {
const key = randomBytes(32);
const nonce = randomBytes(12); // 96-bit nonce for both; never reuse with the same key
const cipher = createCipheriv(algorithm, key, nonce, { authTagLength: 16 });
cipher.setAAD(aad);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const decipher = createDecipheriv(algorithm, key, nonce, { authTagLength: 16 });
decipher.setAAD(aad);
decipher.setAuthTag(tag);
const roundTrip = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
console.log(algorithm.padEnd(18), "ciphertext", ciphertext.length, "bytes + tag", tag.length, "bytes, decrypts:", roundTrip.equals(plaintext));
}
aes-256-gcm ciphertext 30 bytes + tag 16 bytes, decrypts: true
chacha20-poly1305 ciphertext 30 bytes + tag 16 bytes, decrypts: true
How TLS picks between them
TLS 1.3 requires every implementation to support TLS_AES_128_GCM_SHA256 and recommends AES-256-GCM and ChaCha20-Poly1305. Clients without AES hardware list ChaCha20 first; servers that honor that preference serve each client the faster cipher. OpenSSL implements this with the PrioritizeChaCha option (SSL_OP_PRIORITIZE_CHACHA), which prefers ChaCha20 only when the client listed it first, and BoringSSL uses equal-preference groups for the same purpose. The TLS configuration generator produces server settings that keep both suites.
When to choose which
- TLS and QUIC servers: enable both; prefer AES-GCM but let clients that put ChaCha20 first get it.
- FIPS 140-3 environments: AES-GCM; ChaCha20-Poly1305 is not an approved algorithm.
- Mobile, IoT and microcontrollers without AES acceleration: ChaCha20-Poly1305.
- Application-level encryption with random nonces and long-lived keys: XChaCha20-Poly1305 or AES-GCM-SIV.
- JWE:
A256GCM(orA128GCM); ChaCha20 is not in the core JWA registry. - Very large single messages (above 64 GiB): split into chunks with per-chunk nonces (as age does with 64 KiB ChaCha20-Poly1305 chunks); do not rely on per-message limits.
Migration notes
Switching AEADs changes the ciphertext format but nothing else in a well-designed system.
- Version your format: store an algorithm identifier and key identifier with each ciphertext so old data stays readable.
- Keep the key separate per algorithm: derive distinct keys (for example with HKDF and different info strings) rather than reusing one key for both.
- Re-check nonce generation: counters must be persisted across restarts; random nonces must stay within the limits above or move to XChaCha20.
- Re-encrypt lazily: decrypt with the old algorithm and write with the new one when records are touched, unless a compliance deadline requires a bulk migration.
- Benchmark on the real hardware: the speed ranking flips between server CPUs and small devices.