Authenticated Encryption: Detecting Tampering with AES-GCM
Part 3 of 3 in Cryptography Basics: Ciphers, Modes, and Tampering
Post 1 broke the Caesar cipher because its keyspace has 26 entries. Post 2 showed that even AES, a permutation with a 2^256 keyspace, leaks patterns in ECB mode because a strong permutation alone is not a secure cipher, you also need a secure mode of operation. Both posts were still only about confidentiality: can an attacker learn something about the plaintext. This post is about a different question entirely: can an attacker change the ciphertext without you noticing?
The security goal: authenticated encryption
Basic encryption, even done right, only promises confidentiality: nobody without the key can read the plaintext. It says nothing about whether the ciphertext someone hands you is the one that actually got encrypted, versus one that’s been altered in transit. Authenticated encryption (AE) is the primitive that adds that missing guarantee. Encryption takes a key and a plaintext and produces both a ciphertext and an authentication tag, a short value that’s computationally impossible to forge without the key. Decryption takes the key, the ciphertext, and the tag, and only returns a plaintext if the tag actually checks out for that exact ciphertext under that exact key; otherwise it refuses and errors out. That refusal is the whole point: decryption either returns the real plaintext or returns nothing at all, there’s no third option where it quietly hands back garbage. AES-GCM (Galois/Counter Mode) is the AE construction most systems reach for today: it takes a key, a nonce, and a plaintext, and produces exactly that ciphertext-plus-tag pair, with a 16-byte tag.
flowchart TD
K[key] --> ENC[AES-GCM encrypt]
N[nonce] --> ENC
P[plaintext] --> ENC
ENC --> CT[ciphertext]
ENC --> TAG[auth tag]
CT --> DEC{AES-GCM decrypt:<br/>recompute tag, compare}
TAG --> DEC
K --> DEC
N --> DEC
DEC -- tag matches --> PT[plaintext]
DEC -- tag mismatch --> REFUSE[InvalidTag: nothing returned]
Decryption has exactly two outcomes: the real plaintext, or nothing. There is no third path that hands back a wrong-but-plausible answer.
There’s also an extended variant this post uses: authenticated encryption with associated data (AEAD) additionally takes a piece of data that stays in cleartext but still gets folded into the tag computation. That’s useful whenever part of a message has to stay readable, a routing header, say, while still being protected from tampering just as strongly as the encrypted payload.
Why plain encryption is not enough: non-malleability
There’s a second security property, separate from confidentiality, that AE is built to satisfy: non-malleability. Given a ciphertext for some plaintext P1, it should be infeasible for an attacker who doesn’t know the key to produce a different, still-valid ciphertext whose plaintext is related to P1 in any predictable way, flipping a specific bit of P1, for instance, without ever learning what P1 actually is.
Plenty of encryption schemes that hide the plaintext perfectly well still fail this outright. XOR-based stream ciphers are the clearest case: encrypt by XORing the plaintext with a keystream, and an attacker who XORs a known value into the ciphertext produces a new ciphertext whose plaintext is the original XORed with that exact same value, no key required. The scheme can be information-theoretically perfect at hiding the plaintext and still hand an attacker a lever to flip bits in it at will.
AES in CTR mode has the exact same shape as that kind of stream cipher: it XORs the plaintext with a keystream, so flipping a ciphertext bit flips the corresponding plaintext bit under decryption, with no error, no warning, and no way for the receiver to know it happened. That is the concrete failure mode AEAD closes off. The demo below reproduces both sides of it: AES-GCM detects the exact same kind of bit flip that a CTR-mode cipher lets straight through.
The code: encrypt, decrypt, authenticate
Using the cryptography library’s AESGCM class:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
key = bytes.fromhex("7beebc1b12863bf177fb4696640116dc10a999f7a1e9ef1ac5e95f490162f77f")
nonce = bytes.fromhex("6c6c993b3423484ac3d67e19") # 96-bit nonce, GCM's recommended size
aesgcm = AESGCM(key)
plaintext = b"Transfer $100 to account 4471"
ct = aesgcm.encrypt(nonce, plaintext, associated_data=None)
ciphertext, tag = ct[:-16], ct[-16:]
recovered = aesgcm.decrypt(nonce, ct, associated_data=None)
assert recovered == plaintext
Key and nonce are hardcoded here so this post’s captured output is reproducible run to run. A real system must generate a fresh random key per deployment and, critically, a fresh nonce for every single message: reusing a (key, nonce) pair with AES-GCM breaks both confidentiality and the tag’s integrity guarantee.
AESGCM.encrypt returns ciphertext with the 16-byte tag appended right after
it. Running it:
=== AES-256-GCM basic encrypt/decrypt ===
key (32 bytes): 7beebc1b12863bf177fb4696640116dc10a999f7a1e9ef1ac5e95f490162f77f
nonce (12 bytes): 6c6c993b3423484ac3d67e19
plaintext: b'Transfer $100 to account 4471'
ciphertext: c8fcdd4c61dd752f47c64007e9a82a772172a6745c63c6afb8472a37a1
auth tag (16B): 806c5dd426e1b13764f4b3ee380ec2cb
decrypted: b'Transfer $100 to account 4471'
tag verified, plaintext matches original
AEAD in practice: authenticating a cleartext header
A common shape for this is a datagram with a cleartext header and an
encrypted payload, where the header must stay readable (for routing, say)
but should still be protected from tampering. associated_data does exactly
that: it is mixed into the tag computation but never encrypted.
header = b"from:acct-1001|to:acct-4471"
payload = b"Transfer $100 to account 4471"
aad_nonce = bytes.fromhex("862fd7e413217356d21b8967") # fresh nonce (12 bytes) -- never reuse a (key, nonce) pair
aad_ct = aesgcm.encrypt(aad_nonce, payload, associated_data=header)
aad_pt = aesgcm.decrypt(aad_nonce, aad_ct, associated_data=header)
=== AEAD: associated data is authenticated but stays in cleartext ===
nonce (12 bytes): 862fd7e413217356d21b8967
header (cleartext, not encrypted): b'from:acct-1001|to:acct-4471'
payload ciphertext+tag: 8cc46896438d215b2fe3088ef775b9ea9266f54ba2f2a6361abc12ee457fa174f430fbf8c71d010331cc8f7c57
decrypted payload: b'Transfer $100 to account 4471'
Note the fresh nonce: this second call reuses the same key as the basic demo above, so it must use a different nonce or it would leak the XOR of the two plaintexts, the exact confidentiality break a nonce is supposed to prevent.
If an attacker changes a single byte of header before decryption without
touching the ciphertext at all, aesgcm.decrypt still raises InvalidTag.
The header never got encrypted, but it is just as protected against tampering
as the payload.
Flipping one ciphertext bit
Here is non-malleability’s failure mode made concrete, done to an actual ciphertext instead of an equation:
tampered = bytearray(ct)
flip_index = 5
tampered[flip_index] ^= 0x01 # flip the low bit of one byte
try:
aesgcm.decrypt(nonce, bytes(tampered), associated_data=None)
print("DECRYPTED ANYWAY -- this would be a broken AE scheme")
except InvalidTag:
print("InvalidTag raised: decryption REFUSED to return plaintext")
=== Tamper detection: flip one ciphertext bit ===
original ct+tag[5]: 0xdd
tampered ct+tag[5]: 0xdc (single bit flipped)
InvalidTag raised: decryption REFUSED to return plaintext
One bit, out of hundreds, in a 29-byte message. GCM’s tag is computed over the entire ciphertext using a polynomial MAC (GHASH) keyed by a value derived from K, so any change to any bit changes the tag GCM recomputes on decryption, and it no longer matches the tag that was sent. The library refuses to return a single byte of plaintext once that mismatch is detected.
What happens without a tag: AES-CTR
To make the contrast concrete instead of theoretical, run the same bit flip against a plain AES-CTR ciphertext, the mode that’s structurally identical to the XOR-with-a-keystream construction described above:
enc = Cipher(algorithms.AES(ctr_key), modes.CTR(ctr_nonce)).encryptor()
ctr_ct = enc.update(plaintext) + enc.finalize()
tampered_ctr = bytearray(ctr_ct)
tampered_ctr[5] ^= 0x01
dec = Cipher(algorithms.AES(ctr_key), modes.CTR(ctr_nonce)).decryptor()
ctr_recovered = dec.update(bytes(tampered_ctr)) + dec.finalize()
=== Compare: what a non-authenticated cipher (AES-CTR) would do ===
original plaintext: b'Transfer $100 to account 4471'
tampered ciphertext decrypts to: b'Transger $100 to account 4471'
AES-CTR has no tag, so it silently returned corrupted plaintext.
No error, no warning. That's the failure GCM's tag exists to prevent.
“Transfer” became “Transger”. No exception, no corrupted-message flag,
nothing. The receiving application would process a subtly wrong value with
zero indication anything happened, exactly the predictable-bit-flip attack
non-malleability is supposed to rule out. AES-GCM does not tolerate this: it
would have raised InvalidTag on this exact modification, as it did above.
flowchart TD
C0["ciphertext byte 5: 0xdd"] --> Flip["attacker flips one bit -> 0xdc"]
Flip --> GCM["AES-GCM decrypt"]
Flip --> CTR["AES-CTR decrypt"]
GCM --> GCMResult["tag recompute mismatches\nInvalidTag: REFUSES to return plaintext"]
CTR --> CTRResult["no tag to check\nreturns 'Transger $100...' silently"]
Same one-bit flip, same key, two different modes: GCM notices and stops, CTR has no way to notice at all.
Here is the whole run, live:
Where this fits, and where it stops
AES-GCM’s tag buys you exactly one thing: proof that the ciphertext and any associated data you’re decrypting is byte-for-byte what was produced under this key, nothing more, nothing less. It does not, on its own, stop an attacker from simply recording a valid ciphertext-and-tag pair and sending that exact same pair again later, pretending to be the original sender. A tag that verifies correctly the second time doesn’t mean the message is fresh, only that it hasn’t been altered. Closing that gap needs a separate mechanism layered on top, typically a counter or sequence number folded into the associated data so a replayed message fails to match the counter the receiver expects, not a replacement for authenticated encryption. What GCM’s tag does guarantee, and what this post’s demo just showed directly instead of by definition, is that the moment a single bit changes between encryption and decryption, decryption fails instead of quietly handing back the wrong answer.