Ask Java for AES and You Get ECB. Nobody Warns You.

Cipher.getInstance("AES") compiles, runs, encrypts, decrypts, and hands you the weakest mode in the box. Here is the OpenJDK source that makes that decision, and the line where ECB gets chosen for you.

Ask Java for AES and You Get ECB. Nobody Warns You.
Photo by Nathan Dumlao / Unsplash
Article Updated August 23, 2026

Rebuilt against OpenJDK 21, with the provider resolution traced through the actual JCA source and every runtime figure captured by running the code. The JCA's dangerous defaults have not moved in twenty years, which is exactly why they still need writing about.

There is a particular kind of restaurant that looks fine. Clean dining room, decent wine list, staff in pressed aprons. And then you get invited into the back and discover the walk-in is at 45 degrees, the sanitizer bucket is water, and the guy on garde manger has been using the same board for chicken and salad since Tuesday. Nothing looks wrong. Everything is wrong. Nobody has gotten sick yet.

Java's cryptography API is that restaurant.

Here is the line. One line, in a thousand codebases, written by people who did everything else right:

Cipher c = Cipher.getInstance("AES");

It compiles. It runs. It encrypts, and it decrypts, and the tests pass, and it goes to production, and it is ECB mode — the one mode every cryptographer will tell you never to use for anything.

Nobody warned you. There is no deprecation notice, no compiler note, no runtime log line. You asked for AES and the kitchen served you AES, prepared the way the kitchen has always prepared it, which is badly.

What a Kitchen Actually Does About Contamination

The analogy above only pays off if you know how a real kitchen handles this, so here it is properly.

Food safety in a professional kitchen is not about being clean in a general, vibes-based way. It is a set of specific, boring, non-negotiable procedures, and every one of them exists because somebody got sick once and now there's a rule.

Colour-coded boards, so raw chicken and salad greens can never touch the same surface. Sanitizer buckets at a measured concentration, changed on a schedule, tested with strips — not eyeballed. Date labels on everything that goes in the walk-in, so nothing gets used past its window on the strength of "it smells fine." Temperature logs taken at intervals and written down, because a walk-in drifting from 38°F to 45°F looks identical from the outside.

The pattern in all of it: the procedure replaces judgment, specifically because judgment fails under pressure. A cook at 8pm on a Saturday with twenty tickets up is not evaluating whether that board is clean enough. He grabs what's in front of him. So the system has to be built such that whatever is in front of him is safe.

Cryptography works exactly the same way, and the mapping is direct:

Kitchen Cryptography
Colour-coded boards Separate keys for separate purposes
Sanitizer at measured concentration Named parameters, not defaults
Date labels in the walk-in Key rotation
Tamper-evident seal on a delivery GCM authentication tag
Temperature log written down Audit logging
The unlabeled bottle on the line Cipher.getInstance("AES")

That last row is the whole article. Every kitchen has one — a squeeze bottle somebody decanted something into months ago, no label, still sitting there. Everybody sort of knows what's in it. Probably. It's fine, right up until the new guy grabs it mid-rush because it's within reach, and pours it into something that goes out to twelve people.

Cipher.getInstance("AES") is that bottle. It is right there, it is the obvious thing to reach for, and what comes out of it is not what you think.

Tracing How "AES" Becomes ECB

I want to show you where this happens, because "Java defaults to ECB" is the kind of claim people repeat without checking, and the source is right there.

It starts in the parser. From src/java.base/share/classes/javax/crypto/Cipher.java in openjdk/jdk21u, GPL-2.0-only:

        boolean algorithmOnly = (endIdx == -1);
        String algo = (algorithmOnly ? transformation.trim() :
                transformation.substring(0, endIdx).trim());
        if (algo.isEmpty()) {
            throw new NoSuchAlgorithmException("Invalid transformation: " +
                                   "algorithm not specified-"
                                   + transformation);
        }
        if (algorithmOnly) { // done
            return new String[] { algo };
        } else {

endIdx is the position of the first /. No slash means algorithmOnly is true, and the method returns immediately with just the algorithm name. Mode and padding are never parsed because you never supplied them. So far, so reasonable — the API can't invent your intent.

Next stop, the provider. From src/java.base/share/classes/com/sun/crypto/provider/SunJCE.java:

        psA("Cipher", "AES",
                "com.sun.crypto.provider.AESCipher$General", attrs);

A bare "AES" maps to AESCipher$General. And General is thin:

    public static final class General extends AESCipher {
        public General() {
            super(-1);
        }
    }

No mode. No padding. It defers to whatever CipherCore sets up. Which brings us to the actual crime scene, in src/java.base/share/classes/com/sun/crypto/provider/CipherCore.java:

        buffer = new byte[blockSize*2];
 
        // set mode and padding
        cipher = new ElectronicCodeBook(impl);
        padding = new PKCS5Padding(blockSize);
    }

There it is. new ElectronicCodeBook(impl), in the constructor, before anybody has asked for a mode. That is the default state of a CipherCore, and if you never call setMode, that is what encrypts your data.

Cipher.getInstance("AES") is Cipher.getInstance("AES/ECB/PKCS5Padding") with the dangerous part left implicit. The API didn't guess wrong. It picked a default before the algorithm existed and never revisited it.

Check the dates, because they're better than the story usually told. The JCE shipped as an optional package for JDK 1.2 in 1998, back when US export law kept crypto out of the JDK proper. It got folded into J2SE 1.4 in 2002. AES support didn't arrive in SunJCE until J2SE 1.4.2, in 2003.

So ECB-by-default wasn't a decision anybody made about AES. It was the house convention already sitting there for DES and Blowfish, and when AES walked in five years later it inherited the same unlabelled bottle nobody had thought about since. That's how most bad defaults survive — not chosen, just never re-examined.

What ECB Actually Leaks

Picture the pass on a busy night. You cannot see into the pans, you cannot taste anything, you are just watching plates go out. But every plate carrying three dots of sauce in a triangle is the same dish. After an hour you know the menu, you know which items are selling, you know table nine ordered the same thing twice. You never tasted a single bite.

That is ECB against real data.

ECB encrypts each 16-byte block on its own, with no chaining. Same plaintext block in, same ciphertext block out, every time, forever.

byte[] block = "YELLOW SUBMARINEYELLOW SUBMARINE".getBytes();
Cipher ecb = Cipher.getInstance("AES");
ecb.init(Cipher.ENCRYPT_MODE, key);
byte[] ct = ecb.doFinal(block);
System.out.println(Arrays.equals(Arrays.copyOfRange(ct,0,16), Arrays.copyOfRange(ct,16,32)));
Cipher.getInstance("AES") -> AES
identical 16B blocks -> identical ciphertext blocks: true

Two identical blocks of input produced two identical blocks of output. The structure of your plaintext survives encryption intact.

You have seen the ECB penguin — the image encrypted with ECB where you can still make out the bird. It's a great demo and it undersells the problem, because people file it under "images, edge case, not my situation."

Here's your situation. Your database has an encrypted status column. Every row's status is one of six values. With ECB, every row with the same status has byte-identical ciphertext. An attacker who dumps that table doesn't need to break AES — they need to count. The most common ciphertext is probably active. The rarest is probably banned or admin. They now have a histogram of your business, and if they can cause one known row to change status they can label the whole set.

It's the kitchen equivalent of plating every dish identically and letting anyone at the pass watch. They can't read your recipes. They don't need to. They can see exactly which orders are the same.

The Time This Happened to 153 Million People

None of the above is hypothetical. In October 2013, Adobe was breached and roughly 153 million accounts went out the door — internal ID, username, email, password, and password hint.

Adobe hadn't hashed the passwords. They'd encrypted them, with 3DES, in ECB mode, using the same key for every user. And the password hints were stored in plaintext.

Sit with the shape of that, because it is precisely the plating tell from a few paragraphs up. Same password in, byte-identical ciphertext out, for every user who picked it. Nobody needed the key. They just needed to notice which rows matched.

Jeremi Gosney of Stricture Consulting Group did exactly that. He compiled the top 100 passwords in the dump without ever recovering the encryption key — cross-referencing the plaintext hints against the repeated ciphertext blocks. He told Graham Cluley it took about three hours.

The mechanism, in his own framing: symmetric encryption instead of hashing, ECB mode, one key for everyone, a huge supply of known plaintexts, and users who typed their actual password into the hint field. Every one of those is a separate mistake. ECB is the one that turned the rest into a lookup table.

There's a second leak people miss. Block encryption without hashing preserves length — the ciphertext tells you roughly how long the password was, which prunes an attacker's search space before they start.

Randall Munroe called the resulting file the greatest crossword puzzle in the history of the world. He wasn't wrong. The dump had clues (the hints) and a grid (the matching ciphertexts), and the answers filled themselves in.

Two details worth keeping for perspective. Adobe's primary password store had already moved to salted SHA-256 — the breached system was a backup slated for decommission, which is its own lesson about the thing you forgot to turn off. And Schneier noted afterwards that because the key was never recovered, a lower share of these passwords got cracked than in a typical hashed dump. ECB leaked the common ones instantly and held the rest hostage to a key that, if it ever leaks, unlocks all 153 million at once.

That's the unlabeled bottle, poured into production, and served to a hundred and fifty-three million people.

Use GCM, and Know What the Extra 16 Bytes Buy

A delivery shows up at the back door. The tape across the box is intact, the seal is unbroken, and you sign for it. If the tape is cut, you don't inspect the contents and make a judgment call — you refuse it at the dock. You have no idea what happened to that box between the truck and your hands, and neither does anybody else.

That is the difference between encryption and authenticated encryption, and it is worth sixteen bytes.

byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
 
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] out = c.doFinal("attack at dawn".getBytes());
plaintext 14B -> ciphertext 30B (14 + 16B auth tag)
decrypt: attack at dawn

Fourteen bytes in, thirty bytes out. The extra sixteen are an authentication tag, and they're the difference between "encrypted" and "encrypted and tamper-evident."

Flip one bit of that ciphertext:

out[0] ^= 1;
d2.doFinal(out);
tampered byte -> AEADBadTagException

It throws. It does not hand you corrupted plaintext and let you figure it out — it refuses to serve the plate. With CBC you'd get plausible-looking garbage back, and the long, miserable history of padding-oracle attacks lives in the gap between "throws" and "returns garbage."

Two GCM rules matter more than everything else in this article.

The IV is twelve bytes. Other lengths are legal and require an extra derivation step you don't want. Use twelve.

The IV must never repeat under the same key. Not "should rarely." Never. GCM fails catastrophically on nonce reuse — reusing one leaks the authentication subkey and lets an attacker forge messages, and no amount of key strength saves you. Generate it randomly per message from SecureRandom, stick it on the front of the ciphertext, and never, ever derive it from a counter that something might reset. A counter that resets on redeploy is a counter that will reset on redeploy. There is a ceiling on how many times you can do that under one key, and it's covered below.

What "Never Reuse the Nonce" Actually Costs You

I said reuse is catastrophic. Here's the mechanism, because "catastrophic" is doing a lot of unexamined work in most write-ups.

GCM's authentication tag is computed with GHASH, which is a polynomial evaluated over GF(2¹²⁸) with the authentication subkey H as the variable. Encrypt two different messages under the same key and the same nonce and you get two tags built from the same H. Subtract them and the unknowns cancel down to one polynomial equation in one unknown. Find its roots and you have a small candidate set for H.

Antoine Joux described this during NIST's GCM standardisation in 2006. It's known as the forbidden attack, and the name is not decoration.

Recovering H doesn't just break that one message. It exposes the mask, which lets an attacker forge a valid tag for arbitrary ciphertext under that nonce. GCM stops being authenticated encryption and becomes plain unauthenticated counter mode — you keep confidentiality, you lose integrity entirely, and every guarantee the tag was buying evaporates.

This is not a whiteboard attack. In 2016, Böck, Zauner, Devlin, Somorovsky and Jovanovic scanned the internet for it and published the results at USENIX WOOT. They found 184 HTTPS servers repeating nonces — fully broken authenticity, in production, on the public internet. Among them: large corporations, financial institutions, and a credit card company. They shipped a proof of concept that injected content into affected sessions.

They also found over 70,000 servers using random nonces, which is the part that should make you check your own code.

Random Is Fine. Random Has a Budget.

Random nonces were a problem in that scan for a specific reason: TLS's explicit nonce field is eight bytes. Sixty-four bits. Run the birthday bound:

  TLS explicit nonce is 8 bytes = 64 bits:
    2^16 records -> collision prob ~ 1.16e-10
    2^24 records -> collision prob ~ 7.63e-06
    2^32 records -> collision prob ~ 5.00e-01

Four billion records on one key and it's a coin flip. That's why the TLS implementations that get this right use a counter, not SecureRandom.

Now the twelve-byte IV this article told you to use:

  2^20 messages (1,048,576)     -> collision prob ~ 6.94e-18
  2^32 messages (4,294,967,296) -> collision prob ~ 1.16e-10
  2^36 messages (68,719,476,736)-> collision prob ~ 2.98e-08

Ninety-six bits buys you an enormous amount of room — but not infinite room, and this is the caveat I owed you earlier. NIST SP 800-38D caps random-IV GCM at 2³² invocations under a single key. About four billion messages. Past that you are outside the standard's guarantee, and the fix is not a bigger IV, it's key rotation.

For most applications four billion messages per key is a number you will never approach. For a high-volume message bus, a per-request encryption layer, or anything logging at scale, it is a Tuesday. Know which one you are, and if you're the second, put a message counter on the key and rotate before you get there.

If you genuinely cannot guarantee unique nonces — multiple servers encrypting under a shared key with no coordination, say — the answer isn't to hope. It's AES-GCM-SIV (RFC 8452), which is built to survive nonce misuse. Repeat a nonce there and the only thing leaked is that two identical messages are identical. Java doesn't ship it in SunJCE; you'd need a provider like Bouncy Castle.

The Whole Thing, End to End

Everything above is a warning. Here is the actual dish — key generation, encryption, the IV handling, and decryption, in one file that compiles and runs on JDK 21. This is the part you copy.

import javax.crypto.*;
import javax.crypto.spec.*;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
 
public class Envelope {
 
    private static final int IV_BYTES  = 12;   // GCM wants 96 bits. Not 16.
    private static final int TAG_BITS  = 128;  // Full-length tag. Don't shorten it.
 
    // One instance, reused. NOT getInstanceStrong() -- that resolves to
    // NativePRNGBlocking, which reads /dev/random and can stall a cold
    // container at first boot. NativePRNG is already CSPRNG-grade.
    private static final SecureRandom RNG = new SecureRandom();
 
    static SecretKey newKey() throws Exception {
        KeyGenerator kg = KeyGenerator.getInstance("AES");
        kg.init(256);
        return kg.generateKey();
    }
 
    static String encrypt(SecretKey key, String plaintext) throws Exception {
        byte[] iv = new byte[IV_BYTES];
        RNG.nextBytes(iv);
 
        Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
        c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
        byte[] ct = c.doFinal(plaintext.getBytes("UTF-8"));
 
        // The IV is not a secret. It just has to be unique per message.
        // Ship it with the ciphertext or you can never decrypt this again.
        byte[] out = new byte[iv.length + ct.length];
        System.arraycopy(iv, 0, out, 0, iv.length);
        System.arraycopy(ct, 0, out, iv.length, ct.length);
        return Base64.getEncoder().encodeToString(out);
    }
 
    static String decrypt(SecretKey key, String envelope) throws Exception {
        byte[] in = Base64.getDecoder().decode(envelope);
 
        byte[] iv = Arrays.copyOfRange(in, 0, IV_BYTES);
        byte[] ct = Arrays.copyOfRange(in, IV_BYTES, in.length);
 
        Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
        c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
        return new String(c.doFinal(ct), "UTF-8");
    }
}

Run it against the same plaintext twice and tamper with the result, and you get this:

sealed:   1KUn4X+jbu05nvUBE7roDUsqm+G+w5SlrnFGQZoWyn9CZ1iOg8hl+ztJ
opened:   attack at dawn
same input, different envelope: true
tampered -> AEADBadTagException

Three things in four lines of output, and they're the three things ECB could not give you.

It round-trips. Same input, different ciphertext every time, because the IV is fresh per message — which is exactly the property ECB lacks, and the reason someone watching the pass can't learn your menu. And one flipped bit at the end gets refused rather than decrypted into garbage.

It Also Has to Survive Hostile Input

Round-tripping your own data proves nothing. decrypt takes a string from outside your program, which means an attacker picks it. So I threw nine malformed blobs at the code above:

  empty string                         -> IllegalArgumentException
  not base64                           -> IllegalArgumentException
  5 bytes (shorter than IV)            -> IllegalArgumentException
  12 bytes (IV only, no ciphertext)    -> AEADBadTagException
  IV + 1 byte                          -> AEADBadTagException
  flipped IV byte                      -> AEADBadTagException
  flipped tag byte                     -> AEADBadTagException
  truncated by 1 byte                  -> AEADBadTagException
  valid blob, WRONG key                -> AEADBadTagException

Nine for nine, it throws. Nothing returns partial plaintext, nothing returns garbage, nothing silently succeeds. It fails closed.

Two of those deserve a second look. A blob of exactly twelve bytes gives the cipher a valid-length IV and zero ciphertext — and GCM still rejects it, because an empty ciphertext with no tag can't authenticate. And flipping a byte of the IV rather than the ciphertext fails too, since the tag is computed over a stream keyed by that IV.

That behaviour is GCM doing its job, not the wrapper being clever — which is the argument for authenticated encryption in one experiment. Write the CBC equivalent and several of those rows come back as plaintext-shaped garbage that your parser then has to have an opinion about. That opinion is where padding oracles live.

Worth running this against your own crypto wrapper. It takes ten minutes and the failure mode you're looking for is any row that returns instead of throwing.

The IV handling is the part people get wrong, so be clear on it: the IV is not a secret. It is a uniqueness token. You generate it randomly, you prepend it, you ship it in the clear alongside the ciphertext, and the recipient slices it back off. Trying to hide it buys you nothing. Reusing it costs you everything.

What is not in this file, deliberately: where key lives. That is the hard problem, it is harder than everything above, and a flawless Envelope class with the key sitting in a properties file next to the jar is not secure. Key management is a different article and a longer one.

SecureRandom, and the Container That Hangs on Startup

SecureRandom default alg: NativePRNG | provider: SUN
Cipher(AES) default provider: SunJCE

new SecureRandom() on Linux gives you NativePRNG, reading from the OS entropy pool. This is correct, it is cryptographically secure, and it does not block. Use it.

I have watched a team spend the better part of a day on a service that came up dead in a fresh cluster and ran fine everywhere else. Network was clean. DNS was clean. The pod was sitting there waiting for randomness, and nobody looked, because "we ran out of entropy" is not a shape most people carry in their heads. It is the walk-in that has not come down to temperature yet. Everything in the kitchen is correct and you still cannot start service.

SecureRandom.getInstanceStrong() is a different animal, and you can see exactly how different by asking it:

new SecureRandom()               -> NativePRNG / SUN
SecureRandom.getInstanceStrong() -> NativePRNGBlocking / SUN
securerandom.strongAlgorithms = NativePRNGBlocking:SUN,DRBG:SUN
securerandom.source           = file:/dev/random

There it is in the name. getInstanceStrong() is configured separately through securerandom.strongAlgorithms in java.security, and on this JDK it resolves to NativePRNGBlocking, reading /dev/random. Which is the origin of one of the more infuriating incidents in this business: a fresh container, no entropy accumulated yet, blocking during key generation at startup. The service just... sits there. Health checks fail. It looks exactly like a network problem, and three people will spend an hour on the load balancer before anybody thinks about randomness.

Be precise about the risk, though, because the folklore overstates it. On modern Linux kernels /dev/random stops blocking once the pool is initialised — measured on this container it was actually faster than the default:

  default  10k x 12-byte IV ->   56.2 ms
  strong   10k x 12-byte IV ->   26.3 ms

The exposure is the cold start on a freshly booted instance with no entropy yet, not steady-state throughput. But here's the thing: for generating an IV there is no security benefit either way. NativePRNG is already a CSPRNG. You'd be taking on a startup-availability risk to buy nothing.

Which is why the Envelope class above holds one static final SecureRandom built with the plain constructor and reuses it, rather than calling getInstanceStrong() per message. An earlier draft of that code did exactly the wrong thing — reaching for the scarier-sounding API on every encryption. Scarier-sounding is not better.

Modern kernels have largely put this to bed. Recognise the shape of it anyway, because when it happens the symptom points somewhere else entirely.

And never, under any circumstances, use java.util.Random or Math.random() for anything security-relevant.

That isn't folklore. java.util.Random is a linear congruential generator with a 48-bit internal state, and the algorithm is published in the Javadoc. Two consecutive outputs are enough to recover the state and predict everything that follows:

recovered internal state from 2 outputs: true
predicted next: -1211382533 | actual next: -1211382533

Two numbers. Then you own the sequence. If that's generating session tokens, password reset codes, or IVs, an attacker who sees two of them sees all of them. They're linear congruential generators. Observe a couple of outputs and you have the seed. That's not a weak generator, that's a public one with extra steps.

Password Hashing Is a Braise, Not a Sear

Everything else in cryptography wants to be fast. Password hashing wants to be slow, deliberately, and this is the one place where people's instincts work directly against them.

You are not searing a steak. You are doing a six-hour braise, and the six hours are the entire point — you cannot rush it and get the same result, because the time is the mechanism. Whatever the wait costs you per login, it costs an attacker the same on every single guess against a stolen dump. That is the trade, and it only works if you actually pay it.

OWASP's current guidance for PBKDF2WithHmacSHA256 is 600,000 iterations. Worth knowing where that number comes from: it's dated December 2022 and derived from testing against RTX 4000-series GPUs. It's a moving target pinned to whatever hardware crackers can rent, not a constant.

Here's what it costs, measured on this single-core container — JIT warmed, best of three:

      1,000 iterations ->    0.34 ms
    100,000 iterations ->   48.61 ms
    600,000 iterations ->  141.65 ms
  1,000,000 iterations ->  234.41 ms

About 142 milliseconds per login. That is latency you are choosing to pay, forever, on purpose, and it's the same multiplier an attacker eats on every guess against a stolen dump.

And note how close to linear that is — 100k to 600k is 6× the work for roughly 2.9× the time, then a million lands where you'd extrapolate. That linearity is PBKDF2's weakness. Nothing about it gets harder per iteration; you're just buying more of the same cheap operation, which is exactly what a GPU farm is good at.

A word on benchmarking this, because I got it wrong first: an unwarmed run of the same code reported 642ms for 600,000 iterations — four and a half times the real figure. Class loading and JIT compilation land entirely on the first call. Warm it, take the best of several, or your numbers are fiction.

There's one PBKDF2 trap worth checking in your own stack. When the password is longer than the PRF's block size — 64 bytes for SHA-256 — the algorithm pre-hashes it down first. OWASP's own note is that good implementations do that pre-hash once, before the expensive loop, while some do it on every iteration, which makes long passwords wildly more expensive than short ones and hands you a trivial denial-of-service on your own login endpoint.

SunJCE is in the good category. Warmed, best of three, 600,000 iterations:

  password   5 bytes -> best of 3:  144.6 ms
  password  65 bytes -> best of 3:  146.5 ms

Five bytes and sixty-five bytes cost the same. Nobody can hurt your auth service by pasting a novel into the password field. That is worth verifying rather than assuming, because it is a property of the provider, not of PBKDF2 — swap in a different one and you inherit whatever it does.

One more thing about the code above: the salt in those benchmarks is new byte[16] — sixteen zero bytes. Fine for timing, catastrophic in production. A real salt is 16 random bytes from SecureRandom, unique per user, stored alongside the hash. Without it, identical passwords produce identical hashes and you have rebuilt Adobe's problem with a slower algorithm.

For a new system, use Argon2id if you can take the dependency. It's memory-hard, which resists GPU cracking in a way that pure iteration count fundamentally cannot — a GPU farm parallelises PBKDF2 beautifully and chokes on memory-hardness. PBKDF2 remains the right call when you're pinned to the JDK's built-in providers, which FIPS-constrained environments often are.

The Part That Actually Gets People

Everything above is table stakes, and none of it is what breaks real systems.

You can run a spotless kitchen — colour-coded boards, sanitizer tested, every date label current — and still get shut down, because the back door has been propped open with a milk crate since March and nobody thinks about it anymore.

What breaks real systems is that you did all of this correctly and the key is in a properties file in the repo. Or in an environment variable that gets dumped into your logging platform on startup. Or hardcoded in a config that shipped to a mobile client, where "encrypted" means the attacker has both the ciphertext and the key on a device they control.

A perfect AES-GCM implementation with a key anyone can read is not encryption. It's a very expensive way to base64 your data.

Key management is harder than cipher selection, it's less fun to write about, and it is where the actual incidents come from. If you take one thing from this: the moment you get the crypto right, the interesting question stops being which algorithm and becomes who can read the key, how does it rotate, and what happens the day someone leaves the company.

The Short Version

Specify the full transformation, always. AES/GCM/NoPadding, never bare AES, because bare AES inherited a default that predates AES itself and nobody can change it now without breaking the internet.

Twelve-byte random IV per message, from SecureRandom, prepended to the ciphertext, never reused.

128-bit auth tag. Don't shorten it to save bytes.

PBKDF2 at current OWASP iteration counts, measured on your own hardware, or Argon2id if you can.

And when someone hands you a codebase and tells you it's encrypted, go look at the walk-in. Check what mode. Check where the key lives. Check whether the IV comes from a counter. The dining room always looks fine.