AES encryption has two parts. The first is the block cipher itself, which is fixed by a standard and has no settings you can get wrong except the key size. The second is everything around it: the mode of operation, the IV or nonce, how a password becomes a key, and how the pieces are stored. Almost every real-world AES failure happens in the second part. This guide goes through both, with results you can reproduce: the ECB, CBC and GCM behaviour below was measured with Node.js 24 on 2026-09-30, and the ciphertext examples come from the AES Encryption & Decryption tool.
The Cipher: FIPS 197
NIST selected the Rijndael cipher as the Advanced Encryption Standard in October 2000 and published it as FIPS 197 in November 2001; an editorial update (FIPS 197-upd1) followed in May 2023. AES encrypts one 128-bit block at a time. The key is 128, 192 or 256 bits, and the key length sets the number of rounds:
| Variant | Key | Rounds |
|---|---|---|
| AES-128 | 16 bytes | 10 |
| AES-192 | 24 bytes | 12 |
| AES-256 | 32 bytes | 14 |
Each round runs four steps on a 4×4 byte state: SubBytes (a fixed S-box substitution), ShiftRows, MixColumns (a linear mix in GF(2⁸); the last round skips it) and AddRoundKey (XOR with a round key expanded from the main key). You do not choose any of this. Libraries such as the Web Crypto API, OpenSSL and Python’s cryptography implement it the same way, which is why AES output is interchangeable between them once the mode and the format match.
Which key size? All three are approved. AES-256 costs about 40% more rounds than AES-128 and is the usual default when the key comes from a password, because it removes key length from any discussion about margin. The key size never fixes a weak password or a reused nonce, which are the real problems below.
A Block Cipher Needs a Mode
AES on its own turns exactly 16 bytes into 16 bytes. To encrypt a message of any other length you need a mode of operation. NIST defines the classic ones (ECB, CBC, CFB, OFB, CTR) in SP 800-38A and GCM in SP 800-38D. The mode decides whether patterns leak, whether a wrong key is detected, and what you must never repeat.
ECB leaks repeated blocks
ECB encrypts every 16-byte block independently with the same key, so equal plaintext blocks give equal ciphertext blocks. With the key 000102030405060708090a0b0c0d0e0f and the 48-byte input YELLOW SUBMARINEYELLOW SUBMARINEyellow submarine, AES-128-ECB produced:
761ab98c7086c509261f322cb3ffa7d9 ← "YELLOW SUBMARINE"
761ab98c7086c509261f322cb3ffa7d9 ← "YELLOW SUBMARINE" again: same bytes
02ab60c1d6e06d4d9fbb0d98963661fd ← "yellow submarine"
Anyone who sees the ciphertext learns which blocks repeat, without the key. That is enough to see the outline of an image, spot identical records in a database dump, or replay a known block. There is no correct use of ECB for data longer than one block.
CBC hides patterns but not tampering
CBC XORs each plaintext block with the previous ciphertext block before encrypting it, starting from an IV. SP 800-38A (Appendix C) requires the IV to be unpredictable. The predictable IVs of TLS 1.0 are what the BEAST attack (2011) used.
CBC has two structural problems. It needs padding (usually PKCS#7), and it has no integrity check. Serge Vaudenay showed in 2002 that a system which reveals whether the padding of a modified ciphertext is valid lets an attacker decrypt it block by block; this “padding oracle” came back as POODLE against SSL 3.0 (2014), and Lucky Thirteen (2013) used timing to get the same signal from TLS. The padding check is also a poor password check. The openssl enc manual warns that random data passes it more often than 1 time in 256. In our run, decrypting one AES-256-CBC ciphertext with 100,000 random wrong keys, 419 of them “succeeded” and returned garbage, about 1 in 239.
GCM authenticates, but only if the nonce is unique
GCM combines CTR-mode encryption with a GHASH authenticator and appends a tag, 128 bits by default. On decryption the tag is recomputed; if one bit of the ciphertext, the tag, the nonce or the associated data changed, or the key is wrong, decryption fails with no plaintext at all. Web Crypto throws an OperationError, Python’s cryptography raises InvalidTag, and the ZeroTool tool reports “Authentication failed”. That replaces the padding guesswork of CBC with a check an attacker cannot pass by trial: with a 128-bit tag, a guessed tag is accepted with negligible probability.
The price is one strict rule: never use the same key and nonce twice. SP 800-38D recommends a 96-bit (12-byte) nonce (section 5.2.1.1). It requires that the chance of a repeat is at most 2⁻³² (section 8), and with random 96-bit nonces a key may be used for at most 2³² encryptions (section 8.3). A repeat is not a small leak. We encrypted PAY 100 TO ALICE and PAY 999 TO MALLO with the same AES-128 key and the same nonce cafebabefacedbaddecaf888:
ciphertext1 XOR ciphertext2 = 00000000080909000000000c0d050f0a
plaintext1 XOR plaintext2 = 00000000080909000000000c0d050f0a (identical)
ciphertext1 XOR ciphertext2 XOR plaintext1 = "PAY 999 TO MALLO"
Knowing one message reveals the other. The repeat also exposes the GHASH key, which lets an attacker forge valid tags for new messages (Antoine Joux described this in his 2006 comments to NIST on the GCM draft). If you cannot guarantee unique nonces, generate a fresh key for each message, which is what password-based formats with a random salt do.
Passwords Are Not Keys
A key must be 16, 24 or 32 random bytes. A password is a short string with far less entropy, so it has to go through a key derivation function that adds a random salt and makes each guess expensive. PBKDF2 is available everywhere, including the Web Crypto API; the OWASP Password Storage Cheat Sheet gives 600,000 iterations for PBKDF2-HMAC-SHA256. On the laptop used for this page (Node.js 24), 600,000 iterations took about 73 ms and 200,000 took about 25 ms, so one CPU core tests about 14 passwords per second instead of 40. That helps against guessing, but a password from a common list still falls in seconds; the KDF buys time, it does not create entropy.
This code encrypts and decrypts in the same format as the ZeroTool tool: v2: followed by Base64 of salt, nonce, ciphertext and tag. It also reads the tool’s older format without a prefix (200,000 iterations).
// Encrypt a message with AES-GCM (Web Crypto API)
async function encrypt(plaintext, password) {
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
// Derive a key from the password using PBKDF2
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 600_000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Generate a random nonce
const nonce = crypto.getRandomValues(new Uint8Array(12)); // 96 bits
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce },
key,
data
);
// Return "v2:" + base64 of salt + nonce + ciphertext (same format as the ZeroTool AES tool)
const combined = new Uint8Array([
...salt,
...nonce,
...new Uint8Array(ciphertext)
]);
return 'v2:' + btoa(String.fromCharCode(...combined));
}
// Decrypt
async function decrypt(base64, password) {
const encoder = new TextEncoder();
// "v2:" means 600,000 iterations; no prefix is the older format with 200,000
const iterations = base64.startsWith('v2:') ? 600_000 : 200_000;
const combined = Uint8Array.from(atob(base64.replace(/^v2:/, '')), c => c.charCodeAt(0));
const salt = combined.slice(0, 16);
const nonce = combined.slice(16, 28);
const ciphertext = combined.slice(28);
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce },
key,
ciphertext
);
return new TextDecoder().decode(plaintext);
}
A Ciphertext Format You Can Reproduce
AES-GCM output is only useful if the receiver knows where the salt and nonce are. The tool uses this layout, and the code above and below writes the same bytes:
| Part | Bytes | Why |
|---|---|---|
v2: | 3 (text) | Version: PBKDF2 at 600,000 iterations. : is not a Base64 or hex character, so it cannot be part of the payload. |
| Salt | 16 | Random per encryption; gives a new key each time. |
| Nonce (IV) | 12 | Random per encryption; the 96-bit size SP 800-38D recommends. |
| Ciphertext | same length as the plaintext | GCM needs no padding. |
| Tag | 16 | Checked before any plaintext is returned. |
The tool produced this for Meet at 10:30, gate B with the password correct horse battery staple:
v2:l0KuwwrqkW+qHiVFcSad83WDHoteU8LkBJ/dw3N3LGl2PMwh3n8Q9u6wTM1CzpoDINVwd/yy4RRdM7QPNwXfGdc=
88 Base64 characters decode to 65 bytes: 16 + 12 + 21 + 16, because the text is 21 bytes of UTF-8. Both functions in this guide decrypt it, and so does the tool. Encrypt the same text again and the output is different, because the salt and nonce are new; if two runs ever produce the same output, the random number generator is broken.
The same layout in Python with the cryptography package (tested with version 45):
import os
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
def derive_key(password: str, salt: bytes, iterations: int = 600_000) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 256 bits
salt=salt,
iterations=iterations,
)
return kdf.derive(password.encode())
def encrypt(plaintext: str, password: str) -> str:
salt = os.urandom(16)
nonce = os.urandom(12) # 96-bit nonce for GCM
key = derive_key(password, salt)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
payload = salt + nonce + ciphertext
return "v2:" + base64.b64encode(payload).decode()
def decrypt(encoded: str, password: str) -> str:
# "v2:" means 600,000 iterations; no prefix is the older format with 200,000
iterations = 600_000 if encoded.startswith("v2:") else 200_000
payload = base64.b64decode(encoded.removeprefix("v2:"))
salt = payload[:16]
nonce = payload[16:28]
ciphertext = payload[28:]
key = derive_key(password, salt, iterations)
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, None).decode()
# Usage
encrypted = encrypt("Secret message", "my-password")
print(encrypted) # "v2:" + base64 of salt+nonce+ciphertext+tag
decrypted = decrypt(encrypted, "my-password")
print(decrypted) # Secret message
Two formats you will meet that this layout is not compatible with:
- OpenSSL
enc. It does not support GCM at all; its manual says it “does not support authenticated encryption modes like CCM and GCM, and will not support such modes in the future”, and OpenSSL 3.6.1 answers-aes-256-gcmwithenc: AEAD ciphers not supported. Its output starts withSalted__(Base64U2FsdGVkX1) followed by an 8-byte salt, and is normally CBC. - CryptoJS
AES.encrypt(text, "passphrase"). It writes the same OpenSSL-styleSalted__header with AES-256-CBC and an MD5-based key derivation. We decrypted CryptoJS 4.2.0 output withopenssl enc -d -aes-256-cbc -md md5 -a -A; output ofopenssl enc -pbkdf2needs-pbkdf2instead of-md md5.
The tool recognizes the Salted__ header and tells you to use one of those commands instead of reporting a wrong password.
Keys Outside the Code
Most AES incidents are key incidents. Keep keys out of source code and out of git history; load them from the environment or a secrets manager. Use separate keys per environment, so a leaked development key does not open production data. Plan how you will rotate a key: the version prefix in the format above exists so that data written with old parameters can still be read while it is re-encrypted. When a password protects a message, send the password over a different channel than the ciphertext. A password in the same email as the encrypted file protects nothing.
Checks Before You Ship
- Mode is GCM (or another AEAD mode). No ECB anywhere; no CBC without a MAC.
- Every encryption under a key uses a new 96-bit nonce, and a random-nonce key is retired well before 2³² messages.
- Passwords go through PBKDF2-HMAC-SHA256 with at least 600,000 iterations (or scrypt / Argon2) and a random salt of 16 bytes.
- The stored format includes a version, the salt, the nonce and the tag, and decryption failures are reported as failures, never as output.
- Test vectors from another implementation decrypt with yours. The AES Encryption & Decryption tool accepts a raw key and a fixed IV for this, and shows the exact bytes it produces.