AES-256 has a 256-bit key. Brute-forcing a random 256-bit key is not a realistic attack — there aren't enough atoms in the observable universe to make it tractable. The cipher itself is not where encrypted-file decryption fails.
What fails is the step between "human types a password" and "AES gets a key." A password is not 256 bits of entropy. "Correct Horse Battery Staple" is roughly 44 bits by some estimates. "hunter2" is maybe 20. You can't hand that to AES and call the output secure — you've made the keyspace the size of a dictionary, not 2²⁵⁶.
What key derivation actually does
A Key Derivation Function (KDF) takes a password and a salt and produces a fixed-length key. PBKDF2, bcrypt, scrypt, and Argon2 are all KDFs. They don't increase the entropy of your password — nothing can do that — but they do two important things:
First, they're slow. PBKDF2 with 600,000 iterations (OWASP's 2024 recommendation) takes around 100ms to derive a key on a modern laptop. An attacker trying to brute-force your password can only try ~10 passwords per second per CPU core, rather than millions. That's the entire point.
Second, they incorporate a salt, which prevents precomputed attacks. The salt forces the attacker to run the full KDF for each guess against each encrypted file. They can't reuse computation across targets.
The mistake that looks correct
The common pattern that gets this wrong:
const key = new TextEncoder().encode(password); // 32 bytes if password is 32 chars
await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]);
This only works if the password happens to be exactly 32 bytes. If you force users into 32-character passwords, you've imposed a terrible UX for essentially no security gain. In practice, people truncate, pad, or hash with SHA-256 — which is fast and therefore terrible as a KDF. The entropy stays low and the speed problem doesn't go away.
What the correct pattern looks like
PBKDF2 in the browser's Web Crypto API:
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"]
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: crypto.getRandomValues(new Uint8Array(16)),
iterations: 600000,
hash: "SHA-256"
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
The salt gets stored alongside the ciphertext — it's not secret, just random. When you decrypt later, you re-derive the same key from the same password and salt. The 600,000 iteration count is what adds the deliberate slowness.
How much does iteration count matter
A lot. With 1,000 iterations, an attacker with an RTX 4090 can try around 3 million password guesses per second against a target. With 600,000 iterations, that drops to roughly 5,000 per second. If your user's password is "iloveyou", neither is going to save them. But if they have a reasonable password — 12 characters with some variation — 600,000 iterations makes brute-forcing it take decades instead of hours.
Argon2 is better than PBKDF2
PBKDF2 is CPU-bound. GPUs are excellent at CPU-bound tasks, which is why the "3 million guesses per second" number is achievable. Argon2id adds a memory requirement — to compute one hash you need to allocate and access a large block of RAM. GPUs have less memory per core than CPUs, so the attacker's hardware advantage shrinks substantially.
The Web Crypto API doesn't expose Argon2, which is a real limitation for browser-based encryption. PBKDF2 with a high iteration count is the best you can do client-side. For server-side encryption, use Argon2id.
The AES tool on this site uses PBKDF2-SHA256 with a random 16-byte salt and a high iteration count — the approach described above, running in your browser.