Skip to main content
Cryptography · lesson 2 of 8
Cryptography beginner Lesson 2 of 8

Symmetric Encryption with AES-GCM: Tampering Raises InvalidTag

Encrypt with AES-GCM in Python, then flip one bit, use the wrong key or the wrong associated data, and watch each raise InvalidTag instead of garbage.

By SkillByExample · Published

cryptography aes aes-gcm aead encryption python

Symmetric encryption uses one secret key to both encrypt and decrypt. The modern default is an authenticated mode: AES-GCM or ChaCha20-Poly1305. These don’t just hide the data, they refuse to decrypt anything that was changed. This lesson shows that refusal, and what you get without it. Start a container with docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash, then run pip install cryptography==50.0.2. The examples were run with Python 3.12.15 and cryptography 50.0.2. Every key is generated fresh on each run.

The round trip, and the 16 extra bytes

AESGCM.encrypt takes a key, a nonce, the plaintext, and optional associated data, which is authenticated but left readable.

# roundtrip.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)    # demo-only: a new key every run
aesgcm = AESGCM(key)

nonce = os.urandom(12)                       # 96 bits, never reused with this key
plaintext = b"card=4111-FAKE-0000 exp=12/29"
aad = b"user_id=42"                          # authenticated, not encrypted

ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
print("plaintext: ", len(plaintext), "bytes")
print("ciphertext:", len(ciphertext), "bytes =", len(plaintext), "+ 16-byte tag")
print("ciphertext:", ciphertext[:16].hex(), "...")
print("decrypted: ", aesgcm.decrypt(nonce, ciphertext, aad))
$ python roundtrip.py
plaintext:  29 bytes
ciphertext: 45 bytes = 29 + 16-byte tag
ciphertext: 8bb9648b52e02a9c8a965698decb1c2e ...
decrypted:  b'card=4111-FAKE-0000 exp=12/29'

The ciphertext is exactly as long as the plaintext, plus 16 bytes. There’s no padding: GCM is built on counter mode, which encrypts byte for byte. The 16 extra bytes are the authentication tag, computed over the ciphertext and the associated data. Your hex will differ on every run, because the key and the nonce are random. The nonce isn’t secret, but it must never repeat under the same key. Lesson 3 shows what goes wrong when it does.

Three ways to break it, one exception

Now change one thing at a time: a single ciphertext bit, the key, and the associated data.

# tamper.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, b"pay alice amount=100", b"user_id=42")

flipped = bytearray(ct)
flipped[0] ^= 0x01                           # one bit of the first byte

attempts = {
    "one bit flipped": (key, flipped, b"user_id=42"),
    "wrong key":       (AESGCM.generate_key(bit_length=256), ct, b"user_id=42"),
    "wrong AAD":       (key, ct, b"user_id=43"),
    "untouched":       (key, ct, b"user_id=42"),
}
for name, (k, c, aad) in attempts.items():
    try:
        print(f"{name:16}", AESGCM(k).decrypt(nonce, bytes(c), aad))
    except InvalidTag as e:
        print(f"{name:16}", type(e).__name__, "message:", repr(str(e)))
$ python tamper.py
one bit flipped  InvalidTag message: ''
wrong key        InvalidTag message: ''
wrong AAD        InvalidTag message: ''
untouched        b'pay alice amount=100'

Each failure raises InvalidTag, and none of them returns any plaintext, not even a garbled version. The exception’s message is empty on purpose. An error that said “bad key” or “byte 0 modified” would tell an attacker which guess was closer, so the library tells you only that verification failed. The “wrong AAD” case is the most useful in practice: the ciphertext is untouched, but it was sealed for user_id=42, so it won’t open as anyone else’s.

Without authentication, the edit goes through

To see why the tag matters, here’s AES in plain counter mode (CTR), with no tag, on this demo’s own data. CTR encrypts by XORing the plaintext with a keystream.

# ctr_malleable.py
# Why authentication matters: AES-CTR alone, on this demo's own data.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

key, nonce = os.urandom(32), os.urandom(16)
ctr = Cipher(algorithms.AES(key), modes.CTR(nonce))

ct = ctr.encryptor().update(b"pay alice amount=100")

edited = bytearray(ct)
edited[17] ^= ord("1") ^ ord("9")            # no key needed to make this edit

print(ctr.decryptor().update(bytes(edited)))
$ python ctr_malleable.py
b'pay alice amount=900'

The edit used no key. Byte 17 of the plaintext is 1, and XORing the ciphertext with '1' XOR '9' turns it into 9 after decryption, because each ciphertext bit lines up with one plaintext bit. Anyone who knows or can guess the message format can make that edit, and the receiver decrypts it without complaint. This is called malleability. CBC mode has related problems, such as padding oracles. That’s why the rule is to use an AEAD mode (GCM or ChaCha20-Poly1305), or Fernet, and never to bolt a MAC onto a raw mode yourself. The fix for this script is the previous section: the same edit on a GCM ciphertext raised InvalidTag.

Storing the nonce with the ciphertext

The receiver needs the nonce to decrypt. The usual format is to put the 12-byte nonce in front of the ciphertext, because it isn’t secret.

# store.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)

def seal(plaintext: bytes, aad: bytes) -> bytes:
    nonce = os.urandom(12)
    return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)

def open_(blob: bytes, aad: bytes) -> bytes:
    nonce, ct = blob[:12], blob[12:]
    return AESGCM(key).decrypt(nonce, ct, aad)

a = seal(b"same secret", b"row=1")
b = seal(b"same secret", b"row=2")
print("stored sizes:", len(a), len(b))
print("identical blobs?", a == b)
print(open_(a, b"row=1"), open_(b, b"row=2"))
$ python store.py
stored sizes: 39 39
identical blobs? False
b'same secret' b'same secret'

Each blob is 12 bytes of nonce, 11 of ciphertext and 16 of tag, so 39 bytes. The same plaintext encrypted twice gives different blobs, because each call draws a fresh nonce. That matters: if equal plaintexts gave equal ciphertexts, anyone reading the table could see which rows share a secret. seal generates the nonce itself, so callers can’t get it wrong. That’s the shape to copy.

Practice

  1. Stop a ciphertext being moved to another row. Using seal/open_ with the row ID as associated data, copy row 2’s stored blob over row 1, then try to open row 1. What happens, and why?
Solution
# swap_rows.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)

def seal(plaintext: bytes, aad: bytes) -> bytes:
    nonce = os.urandom(12)
    return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)

def open_(blob: bytes, aad: bytes) -> bytes:
    return AESGCM(key).decrypt(blob[:12], blob[12:], aad)

rows = {1: seal(b"salary=50000", b"row=1"), 2: seal(b"salary=90000", b"row=2")}
rows[1] = rows[2]                    # copy row 2's ciphertext over row 1
try:
    print(open_(rows[1], b"row=1"))
except InvalidTag:
    print("row 1: InvalidTag, the swapped ciphertext is refused")
$ python swap_rows.py
row 1: InvalidTag, the swapped ciphertext is refused

The copied blob is a perfectly valid ciphertext, but it was sealed for row=2. Someone with write access to the database but no key can’t give themselves a higher salary by moving ciphertexts around. Without the AAD, the swap would have decrypted.

  1. Switch to ChaCha20-Poly1305. It’s the other standard AEAD, and it’s faster than AES on CPUs without AES hardware instructions. Encrypt and decrypt with it, then flip a bit in the tag (the last byte).
Solution
# chacha.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305

key = ChaCha20Poly1305.generate_key()
nonce = os.urandom(12)
ct = ChaCha20Poly1305(key).encrypt(nonce, b"pay alice amount=100", None)
print("round trip:", ChaCha20Poly1305(key).decrypt(nonce, ct, None))

bad = bytearray(ct)
bad[-1] ^= 0x80                      # flip a bit in the tag itself
try:
    ChaCha20Poly1305(key).decrypt(nonce, bytes(bad), None)
except InvalidTag:
    print("tampered:   InvalidTag")
$ python chacha.py
round trip: b'pay alice amount=100'
tampered:   InvalidTag

The API is the same as AESGCM: a 32-byte key, a 12-byte nonce, and None when there’s no associated data. A corrupted tag fails just like a corrupted ciphertext.

  1. Use Fernet, the high-level recipe. Encrypt a message with cryptography.fernet.Fernet, decrypt it, then change one character of the token.
Solution
# fernet_demo.py
from cryptography.fernet import Fernet, InvalidToken

key = Fernet.generate_key()          # demo-only: urlsafe base64 of 32 random bytes
f = Fernet(key)
token = f.encrypt(b"pay alice amount=100")
print(token[:20], "...", len(token), "chars")
print(f.decrypt(token))

bad = bytearray(token)
bad[40] = ord("A") if bad[40] != ord("A") else ord("B")   # change one character
try:
    f.decrypt(bytes(bad))
except InvalidToken:
    print("tampered token: InvalidToken")
$ python fernet_demo.py
b'gAAAAABqzovkYF43v9Uz' ... 120 chars
b'pay alice amount=100'
tampered token: InvalidToken

Fernet picks the IV, adds a timestamp and an HMAC, and base64-encodes the result, so there’s no nonce to manage. Every token starts with gAAAAA: a version byte, then the start of the timestamp. Lesson 8 uses MultiFernet to rotate Fernet keys.

  1. Truncate and extend the ciphertext. Try decrypting the ciphertext with the whole tag removed, with one byte removed, and with one byte appended. Predict each result first.
Solution
# truncate.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, b"pay alice amount=100", None)

for cut in [ct[:-16], ct[:-1], ct + b"\x00"]:
    try:
        AESGCM(key).decrypt(nonce, cut, None)
        print(len(cut), "bytes: decrypted")
    except InvalidTag:
        print(len(cut), "bytes: InvalidTag")
$ python truncate.py
20 bytes: InvalidTag
35 bytes: InvalidTag
37 bytes: InvalidTag

All three fail. GCM always treats the last 16 bytes as the tag, so stripping the tag doesn’t give you unauthenticated plaintext: the last 16 of the 20 remaining bytes (ciphertext bytes 4 to 19) are read as the tag, and they don’t match. Where AEAD alone isn’t enough is a stream split into many separately sealed chunks: dropping the last chunk leaves every remaining chunk valid. Streaming formats handle that by authenticating each chunk’s position and a “final chunk” flag.

Frequently Asked Questions

What is AES-GCM?
AES-GCM is AES used in Galois/Counter Mode, an authenticated encryption (AEAD) mode. It encrypts the data and also computes a 16-byte authentication tag over the ciphertext and any associated data. Decryption checks the tag first and refuses to return anything if a single bit was changed, so you get confidentiality and integrity from one call.
What does InvalidTag mean in Python's cryptography library?
InvalidTag is raised by AESGCM.decrypt or ChaCha20Poly1305.decrypt when the authentication tag does not match. The cause can be a modified or truncated ciphertext, the wrong key, the wrong nonce or different associated data. The exception deliberately carries no detail, so an attacker learns nothing about which part was wrong.
What is associated data (AAD) in AES-GCM?
Associated data is extra information that is authenticated but not encrypted, such as a record ID, a user ID or a protocol header. Decryption only succeeds if the same associated data is supplied, so binding a ciphertext to its database row ID stops anyone copying it into a different row. It stays readable, so never put secrets in it.
Why is unauthenticated encryption such as AES-CTR or AES-CBC dangerous?
Without a tag, decryption always produces some output, and an attacker can change the ciphertext in ways that make predictable changes to the plaintext. With CTR mode, flipping a ciphertext bit flips the same plaintext bit, so a known amount field can be rewritten without the key. Use an AEAD mode such as AES-GCM or ChaCha20-Poly1305 so tampering is rejected instead.
Should I use AESGCM directly or Fernet?
If you just need to encrypt data in Python and don't need interoperability, Fernet from the cryptography package is the simplest safe choice: it manages the IV, the timestamp and the authentication for you. Use AESGCM directly when you must match a format another system expects, and then you are responsible for generating a unique nonce for every message.