You picked AES-GCM because everyone said it’s fast. Then your code ran on a low-end ARM router, throughput tanked, and you shipped a timing side-channel without knowing it. Or you reached for ChaCha20-Poly1305 because it’s "more modern," and now your HSM vendor is staring at you blankly.
Both ciphers are Authenticated Encryption with Associated Data (AEAD) — meaning they encrypt and authenticate in one pass. Both are in TLS 1.3. Both are unbroken. But they have very different performance profiles, failure modes, and hardware support stories. Picking wrong doesn’t get you hacked today; it gets you into trouble at scale, on unusual hardware, or when a developer makes one specific mistake under pressure.
This article cuts through the marketing and gives you the actual engineering tradeoffs.
What "AEAD" Actually Means and Why It Matters
Before comparing the two, let’s anchor on what both ciphers are doing.
AEAD schemes combine a stream cipher (or block cipher in counter mode) with a MAC (message authentication code). The MAC covers both the ciphertext and "associated data" — headers, metadata, sequence numbers — that travels alongside but isn’t encrypted. The result: a single primitive that prevents tampering and eavesdropping at once.
The practical consequence is that if you decrypt successfully, the message hasn’t been modified. If decryption fails, you throw it away and learn nothing useful. This is the right default for almost everything.
Both AES-GCM and ChaCha20-Poly1305 are AEAD. The differences are in how they get there.
AES-GCM
AES-GCM pairs AES in Counter Mode (AES-CTR) for encryption with GHASH for authentication. The "GCM" stands for Galois/Counter Mode, and the "Galois" part is what does the MAC — a polynomial evaluation over GF(2^128).
Where It Wins
Hardware acceleration is everywhere. Every x86 chip since Sandy Bridge (2011) has AES-NI instructions. ARM has the ARMv8 Cryptography Extensions. With hardware support, AES-GCM reaches 10–40 GB/s on modern server CPUs. A single core can saturate a 10 GbE link without breaking a sweat.
This matters enormously for VPNs, TLS termination proxies, and storage encryption. If your workload is I/O bound and your hardware is mainstream x86 or modern ARM64 (Cortex-A53+, Apple Silicon, AWS Graviton), AES-GCM is the right call.
FIPS 140-3 and compliance. If you’re selling to US federal agencies, healthcare, finance, or anyone with a compliance checkbox that says "FIPS-validated," AES-GCM is your cipher. ChaCha20-Poly1305 is not in the FIPS approved algorithm list as of 2026. That’s not a security statement — it’s a bureaucratic reality that will end procurement conversations fast.
HSM support. Hardware Security Modules from Thales, nCipher, Utimaco, and most smart card platforms support AES. ChaCha20 support in HSMs is still sparse. If your key material lives in an HSM (it should for long-lived keys), AES-GCM is often the only option without custom firmware.
Where It Fails
Nonce reuse is catastrophic. GCM’s authentication is built on a polynomial MAC keyed by an encrypted nonce. Reuse a nonce under the same key and an attacker can recover the authentication key, forge messages, and recover plaintext XOR’d from two ciphertexts. This isn’t theoretical — Joux’s 2006 attack made it concrete, and real systems have been broken this way.
The 96-bit nonce (standard for GCM) gives you 2^32 messages under a single key before collision probability becomes non-negligible. For high-volume systems generating nonces randomly, that’s not enough. You need either a counter-based nonce with strict no-reuse guarantees, or AES-GCM-SIV (see below).
No hardware acceleration = slow and dangerous. Software-only AES is not just slow — it’s vulnerable to cache-timing attacks on platforms without constant-time instructions. Running AES-GCM in pure software on a microcontroller or old CPU is a security problem, not just a performance one.
Short authentication tags are a trap. GCM supports tags from 32 to 128 bits. Some implementations default to 96 or even 64 bits for performance. Below 128 bits you’re reducing forgery resistance. Always use 128-bit tags.
ChaCha20-Poly1305
ChaCha20-Poly1305 pairs the ChaCha20 stream cipher (designed by Daniel J. Bernstein) with the Poly1305 MAC. ChaCha20 is a 256-bit key stream cipher built on a 20-round ARX (add-rotate-XOR) construction. Poly1305 is a one-time MAC that, combined with ChaCha20’s keystream, produces 128-bit authentication tags.
Google standardized this for TLS in 2013 (RFC 7539, later RFC 8439), partly to give Android devices — at the time mostly lacking AES hardware instructions — a fast, safe alternative.
Where It Wins
Software performance on anything without AES-NI. On ARM Cortex-A7, MIPS, RISC-V, ESP32, and similar embedded targets, ChaCha20-Poly1305 is 3–5× faster than software AES-GCM. This is the original motivation and it holds up. If you’re shipping firmware, IoT devices, or embedded Linux on weak hardware, this is your cipher.
Nonce handling is more forgiving. ChaCha20-Poly1305 uses a 96-bit nonce like GCM, but its authentication is not a polynomial MAC keyed by an encrypted nonce. Nonce reuse is still bad — you leak plaintext XOR — but you don’t lose authentication integrity the way GCM does. It’s the difference between "bad" and "catastrophic."
For systems where you can’t guarantee strict counter uniqueness (distributed systems, async code, stateless services), this is a meaningful safety margin.
Timing-attack resistance by design. ChaCha20’s ARX structure has no secret-dependent memory accesses. It’s constant-time on every platform, including ones without hardware acceleration. Poly1305 requires a little care but well-audited implementations (libsodium, BoringSSL, WireGuard) handle it correctly.
WireGuard uses it. If you’ve looked at WireGuard’s protocol design, it uses ChaCha20-Poly1305 exclusively. The reasoning from Donenfeld’s paper: simpler implementation, easier to audit, no AES-NI dependency. That’s a meaningful signal about the cipher’s production readiness.
Where It Fails
Hardware acceleration gap on x86. Intel CPUs don’t have dedicated ChaCha20 instructions. AMD added some in Zen4 (VAES + AVX-512 implementations can get competitive), but in general, on modern server hardware with AES-NI, AES-GCM will beat ChaCha20-Poly1305 by 2–4×. For a busy TLS proxy or storage backend, that matters.
Not FIPS. Not HSM-friendly. Already covered above, but worth repeating: if compliance is a real requirement, ChaCha20-Poly1305 is currently off the table.
QUIC and HTTP/3 performance. QUIC’s packet structure encrypts and authenticates many small packets. Benchmarks on server hardware consistently show AES-GCM-128 ahead for this workload specifically because of AES-NI pipelining. If you’re building QUIC infrastructure on x86, AES-GCM is worth the complexity.
Benchmark Reality Check
Benchmarks without context are useless, but some numbers anchor the intuition. All figures are approximate, vary by implementation, and assume 4 KB payloads:
| Platform | AES-128-GCM | ChaCha20-Poly1305 |
|---|---|---|
| x86-64 with AES-NI (Linux, OpenSSL 3.x) | ~12 GB/s | ~3–5 GB/s |
| ARM Cortex-A72 with crypto ext. (RPi 4) | ~2.5 GB/s | ~1.2 GB/s |
| ARM Cortex-A53 without crypto ext. | ~180 MB/s | ~650 MB/s |
| ESP32 (Xtensa LX6, no AES HW) | ~8 MB/s | ~28 MB/s |
The crossover point is clear: hardware crypto extensions flip the winner entirely.
Test your actual target platform with openssl speed -evp aes-128-gcm chacha20-poly1305 before committing.
Gotchas
Gotcha 1: Random nonces with GCM at high message volume. The birthday bound hits at 2^48 messages with random 96-bit nonces for a 1-in-2^32 collision probability — roughly 280 trillion messages. That sounds comfortable. It isn’t if you have thousands of services generating nonces independently under the same key. Use per-connection keys derived from your main key, and prefer counter-based nonces.
Gotcha 2: Decryption before authentication. Some naive AES-GCM implementations decrypt the entire ciphertext before checking the MAC, then return plaintext to the caller before verification passes. Never do this. Always verify the tag first (or use a library that does), then return plaintext. libsodium’s API physically prevents this by design.
Gotcha 3: Associated data mismatch. If your protocol sends headers in plaintext alongside GCM/ChaCha20 ciphertext but forgets to pass them as AAD (Additional Authenticated Data), an attacker can swap headers between messages. The cipher won’t catch it. Make sure every field that influences interpretation of the ciphertext is in the AAD.
Gotcha 4: Key/nonce combo across rekeying. Rotating keys periodically is good hygiene, but if you reset nonce counters to 0 on rekey and aren’t careful about the transition window, you can briefly operate with duplicate (key, nonce) pairs. Ensure the new key is active before any nonce-0 ciphertext is produced.
Gotcha 5: ChaCha20-Poly1305 is not a substitute when AES-GCM-SIV is what you actually need. If you have a use case where nonce uniqueness is genuinely hard to guarantee (stateless encryption, key-wrapping, file encryption at rest), look at AES-GCM-SIV (RFC 8452) or XChaCha20-Poly1305. Both are nonce-misuse-resistant. AES-GCM and standard ChaCha20-Poly1305 are not.
Production-Ready Code Examples
Go — letting the runtime pick for TLS
// tls_config.go
// Prefer AES-GCM on hardware that has AES-NI;
// fall back to ChaCha20-Poly1305 otherwise.
// Go's crypto/tls does this automatically when you
// leave CipherSuites nil, but for non-TLS AEAD use:
import (
"crypto/aes"
"crypto/cipher"
"golang.org/x/crypto/chacha20poly1305"
"runtime"
"strings"
)
// pickAEAD returns the right AEAD for the current CPU.
// On x86/x86-64 Go's internal hasAESGCMAsm() is not exported,
// so we use GOARCH as a proxy and let the cipher package
// pick the fast path internally.
func pickAEAD(key []byte) (cipher.AEAD, error) {
arch := runtime.GOARCH
hasHWAES := strings.HasPrefix(arch, "amd64") ||
strings.HasPrefix(arch, "arm64")
if hasHWAES && len(key) == 16 {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// 32-byte key for ChaCha20-Poly1305
return chacha20poly1305.New(key)
}
Python — libsodium via PyNaCl
# encrypt.py
# Always prefer libsodium bindings over rolling your own.
# PyNaCl wraps libsodium's ChaCha20-Poly1305 (XSalsa20 variant)
# or use cryptography package for AES-GCM.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
import os
def encrypt_gcm(key: bytes, plaintext: bytes, aad: bytes) -> bytes:
"""AES-128-GCM. key must be 16 bytes. Prepends 12-byte nonce."""
assert len(key) == 16, "AES-128 requires 16-byte key"
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce, cryptographically random
ct = aesgcm.encrypt(nonce, plaintext, aad)
return nonce + ct # nonce is not secret, send it with ct
def encrypt_chacha(key: bytes, plaintext: bytes, aad: bytes) -> bytes:
"""ChaCha20-Poly1305. key must be 32 bytes. Prepends 12-byte nonce."""
assert len(key) == 32, "ChaCha20-Poly1305 requires 32-byte key"
cc = ChaCha20Poly1305(key)
nonce = os.urandom(12)
ct = cc.encrypt(nonce, plaintext, aad)
return nonce + ct
def decrypt_gcm(key: bytes, data: bytes, aad: bytes) -> bytes:
"""Raises InvalidTag if authentication fails — never catch silently."""
nonce, ct = data[:12], data[12:]
return AESGCM(key).decrypt(nonce, ct, aad)
OpenSSL CLI — quick performance test
#!/usr/bin/env bash
# bench_ciphers.sh — compare AES-GCM and ChaCha20-Poly1305 on this host
# Requires OpenSSL >= 1.1.1
echo "=== AES-128-GCM ==="
openssl speed -evp aes-128-gcm 2>&1 | grep -E "^aes|Doing"
echo ""
echo "=== AES-256-GCM ==="
openssl speed -evp aes-256-gcm 2>&1 | grep -E "^aes|Doing"
echo ""
echo "=== ChaCha20-Poly1305 ==="
openssl speed -evp chacha20-poly1305 2>&1 | grep -E "^chacha|Doing"
echo ""
# Check if AES hardware is present
if grep -q "aes" /proc/cpuinfo 2>/dev/null; then
echo "[+] AES hardware instructions detected — AES-GCM preferred"
else
echo "[+] No AES hardware — ChaCha20-Poly1305 preferred"
fi
The Decision Framework
Stop guessing. Answer these four questions in order:
1. Does your compliance requirement mandate FIPS 140-3?
Yes → AES-GCM, no alternatives.
2. Does your target hardware have AES instructions?
Run the benchmark script above. If AES-GCM is more than 2× faster than ChaCha20-Poly1305 → AES-GCM. If ChaCha20-Poly1305 is faster or within 20% → ChaCha20-Poly1305.
3. Can you guarantee nonce uniqueness?
Stateless services, multi-region distributed systems, encrypted files at rest where the encryption function is called without shared state → reach for XChaCha20-Poly1305 (192-bit nonce, much lower collision risk) or AES-GCM-SIV. Do not use standard GCM or ChaCha20-Poly1305 here without a solid nonce management plan.
4. Is this for a TLS library or protocol stack?
Let the library decide. OpenSSL, BoringSSL, and Go’s crypto/tls all implement GREASE-aware cipher preference negotiation. You configure the list; the handshake picks what the client and server both support and prefer. For TLS 1.3: include both TLS_AES_128_GCM_SHA256 and TLS_CHACHA20_POLY1305_SHA256. Modern clients pick GCM on fast hardware, ChaCha20 on mobile.
The Honest Summary
AES-GCM is the workhorse. It’s fast where hardware supports it, FIPS-compliant, and universally supported. Its failure mode — nonce reuse destroying authentication — is bad enough that you need to treat nonce generation as a first-class engineering problem, not an afterthought.
ChaCha20-Poly1305 is the safe harbor for software-only contexts, embedded targets, and situations where you want less catastrophic nonce-reuse failure semantics. It’s not "safer" in general — it’s safer in specific conditions.
The worst decision is picking neither based on real requirements and just inheriting whatever your framework defaulted to five years ago. Run the benchmark. Check your compliance obligations. Think about where your nonces come from. Then commit.