Skip to content
Back to Blog
Security

AES Decryption Failed: Key, IV, Mode and Padding Fixes

AES decryption failed? A wrong key throws a padding error, a wrong IV corrupts only block one, and GCM tags move between languages. Debug yours free online.

16 min read

AES Decryption Failed: Key, IV, Mode and Padding Fixes

When AES decryption failed in your logs, the message you got is probably describing the wrong problem. Four unrelated bugs produce nearly identical symptoms, and the most common one, a wrong key, announces itself as a padding error.

Ranked by how often each one turns out to be the culprit, starting from a CBC decryption that throws BadPaddingException:

  1. The key bytes differ between the two sides. This is by a wide margin the most common cause.
  2. The key derivation differs. Same passphrase, different KDF or iteration count, so different key bytes.
  3. The ciphertext was damaged in transit: truncated, base64 mangled, or round-tripped through a text encoding.
  4. The IV is wrong. This one is real, but it does not throw a padding error. It corrupts sixteen bytes and raises nothing.

The ordering is structural. CBC checks padding as the last step of decryption, after the key has been applied and the chain unwound, so padding is a checksum on everything upstream and it fails loudly no matter which upstream thing broke. You can also skip straight to the bisection in section 9 and paste your ciphertext into the AES decrypt tool.

Everything below was measured on java 1.8.0_162, node v25.8.2 and openssl 3.6.2. Defaults move between versions, so treat the version numbers as part of the result.

1. Start with what your error actually rules out

An AES failure message says almost nothing about the cause and a lot about what the cause cannot be. That makes it good for deleting branches even though it will never hand you the answer.

What you seeWhat it rules outWhat is still live
BadPaddingException, bad decrypt, wrong final block lengthGCM; a pure IV mistake; a decode failurewrong key, wrong KDF, truncated ciphertext, IV bytes eaten as ciphertext, mode mismatch, padding scheme mismatch
GCM Authentication failed, Unsupported state or unable to authenticate datapadding; any theory involving partial outputwrong key, wrong nonce, detached or misplaced tag, wrong tag length, mismatched AAD
No exception, output is garbageevery authenticated modeECB, CTR, CBC that got lucky, mode mismatch, wrong IV

BadPaddingException, bad decrypt, wrong final block length

These three are the same event in three ecosystems: Java, OpenSSL and .NET. It fires at the end of CBC or ECB decryption, when the last plaintext block does not end in a valid PKCS#7 pattern.

The useful part is the negative: getting this far means your base64 or hex decoded and the byte count was a nonzero multiple of 16, so the transport did not shred the data and you are not in GCM. wrong final block length is the exception. There the count was not a multiple of 16, which points at truncation rather than the key, so jump to section 8.

GCM Authentication failed and friends

GCM compares the tag before releasing a single byte of plaintext, as NIST SP 800-38D requires. That makes it honest in a way the padding error is not: something in the tuple (key, nonce, ciphertext, additional authenticated data, tag) does not match what the encryptor used. It cannot tell you which element, and it never will, because narrowing that down is deliberately outside what the algorithm does. Section 6 covers the element that breaks most often across languages: where the tag sits in the output.

No error, but the output is garbage

This is the dangerous outcome, because a dashboard records it as success. CTR never throws and ECB never throws. CBC throws only when the final byte pattern fails the padding check, and with a wrong key that byte is effectively random, so roughly one attempt in 256 lands on 0x01 and validates. A little under 0.4% of wrong-key CBC decryptions “succeed”. Garbage has a shape, though, and the shape names the bug: sections 4 and 5 have the two fingerprints worth memorising.

2. The most misleading error in AES

One measurement reorders most people’s debugging priorities. Key 0123456789abcdef, all-zero IV, AES/CBC/PKCS5Padding, plaintext hello world, on java 1.8.0_162 with the JDK’s built-in SunJCE provider:

ScenarioChangeMeasured result
AKey wrong by 1 byte (last character fX)throws javax.crypto.BadPaddingException: Given final block not properly padded. The padding itself was never malformed; the error is misleading
BKey correct, IV wrong by 1 byteno exception, plaintext hello world came back as iello world. Only the corresponding byte of the first block was damaged
CKey correct, decrypt the CBC ciphertext with AES/ECBsilently succeeded, no exception. A mode mismatch does not have to raise anything

Scenario A misdirects entire afternoons. Scenario C ships bad data to production.

Why a wrong key produces a padding error

Nothing about the padding was wrong. The encryptor appended five 0x05 bytes to bring hello world up to sixteen, encrypted that block, and it is sitting in your ciphertext unharmed.

The failure happens on the way out. CBC decryption runs the block cipher in reverse, XORs each result with the previous ciphertext block, and only then reads the tail of the final block to decide how many bytes to strip. With the wrong key the cipher produces sixteen bytes of noise, and noise almost never ends in a valid PKCS#7 pattern. The library reports what it saw, bad padding, which is true and useless.

Read BadPaddingException as “the plaintext I reconstructed does not end the way padded plaintext ends”. The most likely reason your reconstruction is wrong is the key, which is why a search for aes decrypt wrong key and a search for a bad padding exception land you in the same threads: the two symptoms are one symptom. One design note while you are in there. Never expose that distinction to a caller, because telling “padding invalid” apart from “padding valid, content wrong” is what a padding oracle attack feeds on (Vaudenay, EUROCRYPT 2002).

What GCM does differently

GCM inverts the order, verifying the tag before producing any plaintext, so there is no window in which partially correct bytes exist. A GCM failure never leaves you wondering whether the output is real, because there is no output. GCM also has no padding at all, being a counter mode underneath, so ciphertext length equals plaintext length. A padding error in a system you thought was GCM therefore proves the system is not GCM, usually a config that fell back to CBC.

3. Are both sides using the same key bytes?

AES does not see your key string. It sees 16, 24 or 32 bytes. Two systems can hold identical key material in a config file and still disagree, because the text matching says nothing about what each side decodes it into.

The three ways a key string gets turned into bytes

Hand the literal string 0123456789abcdef to three different libraries:

as hex          -> 8 bytes    (invalid AES key length)
as base64       -> 12 bytes   (invalid AES key length)
as raw UTF-8    -> 16 bytes   (valid AES-128)

Sixteen characters produce three different byte counts. The case is nasty precisely because it is valid under all three readings: every character is in both the hex and base64 alphabets, and sixteen characters is a legal length for both decoders, so nothing errors at parse time.

The JWT invalid signature troubleshooting guide has the full cross-library matrix of how each ecosystem interprets a secret string; the short version for AES is to write down which encoding your key material is in and make both sides decode explicitly. The HMAC form of the same bug bites webhook receivers, covered in the webhook signature verification guide.

AES is strict: exactly 16, 24 or 32 bytes

This is where AES differs from the primitive most developers meet first. HMAC accepts any key length: RFC 2104 hashes anything longer than the block size and zero-pads anything shorter, so an HMAC generator takes a 7-byte or 700-byte secret without complaint. AES has exactly three legal key lengths and rejects everything else before a single block is processed.

The strictness helps, because a length error is the one AES failure that names its own cause instead of hiding behind padding. Our tool phrases it as Key must be 16, 24, or 32 bytes (AES-128/192/256). The traps that produce a wrong length:

  • A trailing newline from KEY=$(cat key.txt) or echo "$KEY". Use printf and echo -n instead. A trailing space pasted out of a secrets manager UI does the same thing.
  • A 0x prefix copied out of a debugger, which leaves thirty-four characters that are no longer valid hex.
  • Non-ASCII characters. contraseña is 10 characters and 11 bytes in UTF-8, so a “32-character” passphrase with one accented letter is 33 bytes.

SecretKeySpec and the platform default charset

Java has a version of this that only appears after deployment. "my secret".getBytes() with no argument uses the platform default charset, which before JDK 18 came from the file.encoding property and therefore from the machine’s OS and locale. A laptop on UTF-8 and a container on ANSI_X3.4-1968 produce different bytes for any non-ASCII character. JEP 400 made UTF-8 the default in JDK 18, which fixes new code and nothing else.

// wrong: bytes depend on the machine
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");

// right: bytes depend on nothing
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");

If your code works locally, fails on the server with a padding error, and the passphrase contains anything outside ASCII, check this first.

4. The IV: where it goes and how a wrong one looks

An aes iv mismatch is the failure people suspect first and diagnose last, because it does not behave like the others: it is quiet, and its damage is local.

A wrong IV corrupts exactly one block

Look again at scenario B. Key correct, IV wrong by one byte:

hello world   ->   iello world

Nothing threw, and exactly one character changed. Write down the CBC step for the first block and it is obvious: P1 = D(C1) XOR IV. The IV is XORed straight into the first plaintext block and touches nothing else, so flipping one bit of the IV flips the same bit of the plaintext in the same position. Here h (0x68) became i (0x69), so the IV’s first byte moved by exactly 0x01.

That gives you a fingerprint. In CBC, first 16 bytes garbage and everything after them clean means the IV is wrong and the key is right. Every block garbage means the key is wrong. That one observation separates the two most common causes without changing a line of code, and the AES decrypt tool shows the decoded bytes so you can read it directly.

As for why nothing threw: hello world is 11 bytes, so it is a single block, and the PKCS#7 padding lives in bytes 11 through 15 of it. The IV byte that changed was byte 0, so the padding region was untouched and validated. Corrupt an IV byte at position 11 or later and you get a padding error instead, which is another route by which the padding error lies to you.

Three transmission conventions

No standard says where the IV goes. Instead there are three habits, and they interoperate badly.

Prepending it, as iv || ciphertext, is the most common convention and the default in our tools. Both sides must agree on how much to strip: 16 bytes for CBC and CTR, 12 for GCM. The mirror-image bug is a producer that prepends and a consumer that does not. The first 16 bytes of “ciphertext” are then the IV, every block shifts, and you get a padding error.

Giving it a separate field, {"iv": "...", "ciphertext": "..."}, is cleaner in principle and doubles the places an encoding can disagree, since the IV now has its own base64-versus-hex question.

The third habit is a fixed constant, usually all zeros, hardcoded because someone needed determinism. It interoperates perfectly, which is what makes it the dangerous one: in CBC a fixed IV leaks equality across records, and in GCM reusing a nonce under one key reveals the XOR of the two plaintexts and can expose the GHASH subkey that authenticates the tag. SP 800-38D is explicit about uniqueness.

The tool’s bare-ciphertext switch plus an explicit IV override tests all three conventions against the same bytes in a minute.

GCM’s IV is 12 bytes, not 16

Teams that adopt GCM by editing an existing CBC path carry the 16-byte IV across, and the result fails without a clue.

SP 800-38D standardises a 96-bit IV. Other lengths are permitted but they are not simply “a longer IV”: when the IV is not 96 bits, GCM derives its initial counter block by running the IV through GHASH instead of using it directly. The same 16 bytes used as a nonce therefore produce a completely different keystream and tag than the first 12 would, and you get a generic authentication failure. If the ciphertext came from elsewhere and you are guessing at the layout, count backwards: the tag is the last 16 bytes, the nonce almost always the first 12.

5. Mode mismatch, including the silent kind

Cipher.getInstance("AES") is ECB

Java lets you name a cipher without naming a mode or a padding scheme. It does not refuse and it does not warn. Under the JDK’s built-in SunJCE provider it fills the blanks with ECB and PKCS5Padding.

Proving it needs the right experiment: encrypt 32 identical bytes (two blocks of A) with key 0123456789abcdef, then check whether the two ciphertext blocks match. On java 1.8.0_162:

getInstance("AES")           ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
  block1 = 3bfd04cc0d7ed55358e2cbe19de21383
  block2 = 3bfd04cc0d7ed55358e2cbe19de21383   <- identical blocks = the ECB fingerprint (plaintext structure leaks)
getInstance("AES/CBC/PKCS5Padding")  blocks differ = chaining is active

The two blocks match byte for byte. That is the ECB signature, the same property that makes the famous encrypted-penguin image still look like a penguin. The experiment only works with identical plaintext blocks: sixteen A bytes followed by sixteen B bytes produce two different ciphertext blocks under ECB as well, and you would wrongly conclude the default was CBC.

Scope the result carefully: it describes the JDK’s built-in SunJCE provider on the version above. The default transformation is a provider decision, so another provider such as BouncyCastle can resolve the same shorthand differently. What generalises is the weaker claim: an unqualified transformation string means whatever your provider decides, which is the reason never to write one.

The wrong mode may not raise an error

Scenario C decrypted CBC ciphertext with AES/ECB and returned the correct plaintext with no exception. That looks impossible until you write out the arithmetic. CBC encryption of the first block is C1 = E(P1 XOR IV), and ECB decryption of that block is D(C1) = P1 XOR IV. The IV here was all zeros, so P1 XOR 0 = P1 and the first block decrypts perfectly. hello world is one block long, so “the first block” was the whole message.

The rule generalises: with a zero IV, ECB and CBC agree on the first block and disagree on every block after it. Decrypt a long CBC message as ECB and you get sixteen clean bytes followed by noise, the exact inverse of the wrong-IV fingerprint. Two opposite shapes point at two different bugs, and neither one produces an error message. Hardcoded zero IVs are common enough that this comes up outside the laboratory.

What a minimal call gives you in each language

EcosystemMinimal callMode you actually get
Java (SunJCE)Cipher.getInstance("AES")ECB with PKCS5Padding, silently
Node cryptocreateDecipheriv('aes-256-cbc', key, iv)whatever the algorithm string says; no default exists
Web Cryptocrypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...)named explicitly; ECB is not implemented at all
Python cryptographyCipher(algorithms.AES(key), modes.CBC(iv))the mode object is mandatory
PyCryptodomeAES.new(key, AES.MODE_ECB)mandatory argument, but ECB is right there in the autocomplete
Go crypto/aesaes.NewCipher(key) returns a raw cipher.Blockcalling Decrypt on that block is ECB; wrap it in cipher.NewCBCDecrypter or cipher.NewGCM
CryptoJSCryptoJS.AES.decrypt(ct, "passphrase")CBC, PKCS#7, EVP_BytesToKey with MD5 (see section 7)

Ecosystems where the mode lives in a string or an object never surprise you. The two that offer a “just AES” call, Java and Go, are where the accidental-ECB reports come from. If the code does not tell you which mode produced a given ciphertext, switch modes against the same bytes until one of them returns something readable.

6. GCM: the same bytes, different APIs

Most cross-language aes gcm auth tag failures are not cryptographic. Both sides computed the same 16 bytes and disagree about where those bytes live.

The measurement

Key = 32 bytes 0123456789abcdef0123456789abcdef, IV = 12 zero bytes, plaintext hello world, on node v25.8.2 and java 1.8.0_162:

Node   ciphertext = a616cd6d7d2328379d41e5                    (11 B)   <- update+final
       authTag    = c87af9f8ad7148e873fa797292c0af3f          (16 B)   <- fetched separately via getAuthTag()
Java   doFinal()  = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f   (27 B)   <- ciphertext and tag already concatenated

Node ciphertext || authTag is exactly Java doFinal(), all 27 bytes of it. There is no encoding difference to negotiate; Node simply hands you the two pieces separately and Java hands them over glued together. The 11 bytes of plaintext also gave 11 bytes of ciphertext, because GCM adds no padding. That is why a padding error can never come from a genuine GCM path.

Concatenated or separated, by runtime

RuntimeEncrypt APIWhere the tag ends up
Node cryptoupdate() + final(), then getAuthTag()separate
Java (SunJCE, AES/GCM/NoPadding)doFinal()appended
Go cipher.AEADSeal()appended
Python cryptography, AESGCMencrypt()appended
Python cryptography, Cipher + modes.GCMfinalize(), then encryptor.tagseparate
Web Cryptocrypto.subtle.encryptappended

Node is the odd one out among the high-level APIs, which is why Node-to-anything is the most reported direction of failure. When you inherit a blob and cannot tell which convention produced it, check whether the last 16 bytes are the tag before you go near the key. Packing Node output for a Java, Go, Python or browser consumer:

const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]);   // now matches doFinal()

Unpacking a concatenated blob for Node:

const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16));  // must come before final()
const pt = Buffer.concat([
  decipher.update(packed.subarray(0, packed.length - 16)),
  decipher.final(),
]);

The ordering constraint is real: call setAuthTag() after final() and Node throws Unsupported state or unable to authenticate data even when every byte is correct.

Tag length is configurable, and the unit differs by API

GCM permits tags of 128, 120, 112, 104 or 96 bits, with 64 and 32 reserved for constrained applications (SP 800-38D, Appendix C). Almost everyone uses 128, and the trouble is how each API asks for it. Java’s new GCMParameterSpec(128, iv) takes its first argument in bits. Web Crypto’s { name: 'AES-GCM', iv, tagLength: 128 } is also in bits and defaults to 128. Node’s createCipheriv(algo, key, iv, { authTagLength: 16 }) is in bytes.

new GCMParameterSpec(16, iv) is a legal-looking Java line that asks for a 16-bit tag; some JDKs reject it, and where it is accepted you have swapped your integrity guarantee for a one-in-65,536 coin flip. When the two sides disagree on tag length the packed lengths differ too, so the receiver slices at the wrong boundary and gets an authentication failure that has nothing to do with the key.

7. You have a passphrase, not a key

If either side takes a human-typed string, there is a key derivation function between that string and AES, and a KDF mismatch is invisible. It never errors. It returns 32 perfectly good bytes that happen to be the wrong 32 bytes, and the failure surfaces one layer down as (you know this one) a padding error.

PBKDF2 needs four things to line up

  • The salt. In the OpenSSL Salted__ format it is 8 bytes inside the ciphertext; in our tools’ passphrase format it is a 16-byte prefix; in hand-rolled schemes it is frequently a hardcoded constant.
  • The iteration count. openssl enc -pbkdf2 defaults to 10,000. OWASP currently recommends 600,000 for PBKDF2-HMAC-SHA256, which is what our passphrase mode uses. Frameworks pick their own numbers.
  • The hash. SHA-1 versus SHA-256 versus SHA-512. Older code and some mobile SDKs still default to SHA-1.
  • The output length. Thirty-two bytes for AES-256, sixteen for AES-128. Some schemes derive key and IV together from one longer call, which never matches a plain 32-byte derivation.

EVP_BytesToKey, and why CryptoJS keeps not working

cryptojs aes decrypt not working is usually one specific mismatch. CryptoJS.AES.encrypt(text, "passphrase") does not use PBKDF2. It uses EVP_BytesToKey, OpenSSL’s pre-1.1 derivation, with MD5 and a single iteration.

EVP_BytesToKey also does something PBKDF2 does not: it derives the key and the IV from the passphrase and salt in one pass. That is why an OpenSSL Salted__ file carries no separate IV field, and why reproducing CryptoJS output with PBKDF2 plus a random IV is wrong twice over.

The format is recognisable on sight: the 8 ASCII bytes Salted__ followed by an 8-byte salt, base64-encoded, always begin U2FsdGVkX1. If your ciphertext starts that way it is passphrase-derived and you need to know which derivation; the AES decrypt tool detects the prefix and switches between the three without code changes.

Why the same password gives different keys

There is no such thing as “the AES password”. Every library invented its own path from string to key:

ProducerDerivationResult for one passphrase
CryptoJS AES.encrypt(text, pass)EVP_BytesToKey, MD5, 1 iterationkey A
openssl enc 1.0.2 and earlierEVP_BytesToKey, MD5, 1 iterationkey A
openssl enc 1.1+ without -pbkdf2EVP_BytesToKey, SHA-256, 1 iterationkey B
openssl enc -pbkdf2PBKDF2-HMAC-SHA256, 10,000 iterationskey C
Our passphrase modePBKDF2-HMAC-SHA256, 600,000 iterationskey D
Java, Python, Gono default at all; you write the derivationwhatever you wrote

Four keys from one password before anyone has made a mistake. The 1.0.2-to-1.1 upgrade changed the default digest from MD5 to SHA-256, which is why ciphertext from old scripts stopped decrypting with the same command on a newer box. If you inherited data and nobody remembers the toolchain, try the derivations in that order. There are only three to test, so it finishes in minutes.

8. What the transport did to your bytes

Ciphertext is uniformly random binary, which makes it hostile to anything that treats bytes as text. A large share of AES failures never involve the cipher.

Base64 variants and missing padding

Standard base64 (RFC 4648 §4) uses + and /; the URL-safe variant (§5) uses - and _. A URL-safe string handed to a standard decoder either throws or, in lenient decoders, silently discards the offending characters and returns short, misaligned bytes, which is why Java ships Base64.getUrlDecoder() and Base64.getDecoder() as separate objects. Some encoders also drop the trailing =, some decoders insist on it, and JWT-adjacent code paths strip it by default.

Before suspecting the key, decode the ciphertext and check its length against the mode:

  • CBC and ECB want a nonzero multiple of 16. Anything else is truncation or a decode problem rather than a key problem.
  • GCM ciphertext is the length of the plaintext, plus 16 for the tag, plus 12 at the front if the nonce is prepended.
  • CTR accepts any length, so this check tells you nothing.

The Base64 decoder gives you the byte count in one paste, often the fastest measurement in the whole investigation.

Newlines, smart quotes and the UTF-8 round trip

openssl base64 wraps output at 64 columns unless you pass -A, and some decoders skip embedded newlines while others reject them, so the same file decodes on one machine and fails on another. Copying through a chat client or a document editor turns straight quotes curly and hyphens into en dashes, and the difference is nearly invisible in a terminal.

The unrecoverable one is a UTF-8 round trip. If raw AES output is ever held as a string without being encoded first (new String(cipherBytes) in Java, bytes.decode('utf-8', errors='replace') in Python, a TextDecoder anywhere), every byte sequence that is not valid UTF-8 collapses to U+FFFD, and encoding it back gives you EF BF BD where your data used to be. Since roughly half of random bytes are non-ASCII, most of the ciphertext is destroyed and no key recovers it; the UTF-8 and UTF-16 encoding guide covers why the loss is one-way. Binary ciphertext travels as base64, as hex, or as binary. Putting it in a string is what destroys it.

Database columns

Storage applies the same damage more quietly. Ciphertext written to a VARCHAR(255) that is one block too long gets cut, and MySQL outside strict mode does it without an error. The tail is where the padding block and the GCM tag live, so a row written “successfully” months ago now fails, and if the cut landed on a 16-byte boundary the length check above will not catch it either. Charset conversion does the rest: a latin1 column receiving UTF-8 bytes rewrites your data on the way in.

Store ciphertext in VARBINARY, BLOB or bytea, or store base64 in a text column with room to spare.

9. A bisection workflow that finds it in five minutes

Every section above narrows one variable. Running them in order against a reference implementation you control converges fast, and the browser tools work well as that reference because they run entirely in your browser, where your key and ciphertext never leave the page, and you can change one setting at a time and see the bytes.

  1. Step 0: measure the shape. Decode the ciphertext and note the byte count, the first few bytes, and whether it begins U2FsdGVkX1. Check the count against section 8. If it is not a multiple of 16 and you believe you are in CBC, stop: this is a transport bug.
  2. Step 1: encrypt a known plaintext. In the AES encrypt tool, encrypt a short known string with the parameters you believe production uses, then compare the shape of the two outputs rather than their values: total length, prefix bytes, presence of a salt header. A mismatch means your assumption about the format or the KDF is wrong, and no amount of key-fiddling fixes it.
  3. Step 2: cycle the derivations. For passphrase-derived data, run PBKDF2 with the exact iteration count, then EVP-SHA256, then EVP-MD5 in the AES decrypt tool. Exactly one can be right. If none works, the bug is above the KDF.
  4. Step 3: remove every convention. Switch to a raw key, turn on bare ciphertext, supply the IV explicitly. You are now stating exactly which bytes are key, IV and ciphertext, with nothing inferred. If it decrypts here but not in your code, your bug is a framing bug (an unstripped IV prefix, a tag in the wrong place) rather than a cryptographic one.
  5. Step 4: flip the mode. Try CBC, then CTR, then GCM against the same bytes. CTR returning readable text where CBC failed means you have a mode mismatch and nothing else.
  6. Step 5: read the garbage. First block bad and the rest clean means the IV. First block clean and the rest bad means you decrypted CBC as ECB with a zero IV. Everything bad means the key or the derivation.

10. FAQ

Why does my AES code work locally but fail in production?

AES code that works locally and fails in production means the environment changed something that is not in source control. Usual suspects, in order: the key arrived from an environment variable or secret manager with a trailing newline; Java’s platform default charset differs between laptop and container, so getBytes() produced different bytes (section 3); production OpenSSL is 1.1+ while your local scripts targeted 1.0.2, changing the EVP_BytesToKey digest from MD5 to SHA-256; or a database column truncates the ciphertext in one environment only. Print the key length and ciphertext length in bytes on both sides first, because those two numbers usually settle it.

I encrypted in Node and can’t decrypt in Java. Where do I start?

When Node encrypts and Java cannot decrypt, start with the GCM tag, the most common and least obvious cause. Node returns ciphertext and tag separately; Java’s doFinal() expects them concatenated as ciphertext || tag, and section 6 shows the bytes are otherwise identical. On CBC instead, start with the IV convention: did Node prepend it, and does the Java side strip 16 bytes before decrypting? Third is the key itself, where Buffer.from(k, 'hex') and k.getBytes(StandardCharsets.UTF_8) produce different lengths from the same string.

Is Java’s PKCS5Padding the same as PKCS#7?

For AES, Java’s PKCS5Padding is PKCS#7 in effect. PKCS#5 (RFC 8018) is defined only for 8-byte blocks; PKCS#7 (RFC 5652) generalises the scheme to block sizes from 1 to 255 bytes. Java’s PKCS5Padding applied to a 16-byte block cipher implements PKCS#7 behaviour, and the name is a historical leftover, so this is never your bug. NoPadding is: it requires plaintext already a multiple of 16, and on decryption it hands the padding back as data, so you see plausible text with trailing bytes like \x05\x05\x05\x05\x05.

My key is 32 characters but AES says the key length is invalid. Why?

A length error means the library got a byte count that is not 16, 24 or 32. With a 32-character string that is usually a trailing newline (33 bytes), a 0x prefix that makes the string invalid hex, or a non-ASCII character occupying two or three bytes in UTF-8. The more dangerous variant is getting no error at all: 32 hex characters decode to 16 valid bytes and 32 base64 characters decode to 24 valid bytes, both legal AES lengths. The library accepts them, uses the wrong key, and hands you a padding failure instead. Whatever the string looks like, the byte count is the number to check.

Decryption “succeeded” but the output is garbage. What went wrong?

Decryption that “succeeded” and handed you garbage means you are in a mode that verifies nothing. CTR and ECB never throw, and CBC throws only when the final byte pattern fails the padding check, which a wrong key passes a little under 0.4% of the time. Read the shape: first 16 bytes corrupt and the rest clean means the IV; first 16 clean and the rest corrupt means you decrypted CBC ciphertext as ECB with a zero IV; uniformly corrupt means the key or the derivation. Readable text with a few odd trailing bytes means NoPadding on padded data. The durable fix is GCM, so that “succeeded” means something.

Can I still decrypt if I lost the IV?

Without the IV you can still decrypt everything in CBC except the first 16 bytes. Blocks 2 onward are recovered as D(C_i) XOR C_{i-1}, and every input to that is already in the ciphertext, so only the first block needs the IV. If you also know how the plaintext starts, say a JSON blob beginning {"userId":, you can recover the IV outright as D(C1) XOR P1. In CTR the IV seeds the entire keystream, so losing it loses everything. In GCM the nonce feeds both the counter and the tag, so there is no partial recovery.

Can I recover the plaintext if the GCM tag was truncated or dropped?

A truncated or dropped GCM tag still leaves the plaintext recoverable: mathematically yes, practically with effort. GCM is CTR mode underneath, so the key and nonce alone reproduce the keystream. No mainstream library will do it for you: Java, Go, Python and Web Crypto all refuse to release plaintext without a valid tag, by design. The workaround is to decrypt the same bytes as AES-CTR with the initial counter block set to the 12-byte nonce followed by 00000002, which is where GCM’s first data block starts. You get the data back and give up every integrity guarantee, so treat the result as untrusted. If you still have all 16 tag bytes and authentication fails anyway, the tag is not missing and something else on this page is your bug. Take it to the AES decrypt tool and start at step 0.

Tags: aes encryption debugging cryptography interoperability

Related Articles

View all articles