Weak cryptography in AI-generated authentication code
By Seaworthy · May 26, 2026
There is a specific kind of dread that comes from reading AI-generated authentication code. The logic is usually coherent. The variable names are clean. And buried in the middle of it, quietly, is md5(password). Every time.
This is not a hallucination. It is something more insidious: accurate reproduction of patterns that were correct once, in a world where cracking a password hash required serious hardware. That world is gone. The training data is not.
Why LLMs reproduce outdated crypto
Language models learn from code that was written, published, and indexed. A tutorial from 2009 that hashes passwords with MD5 carries exactly as much weight as one from 2023 that uses Argon2, because both exist in the corpus. The model has no concept of which is dangerous. It just knows that both are common patterns for "hash a password."
The result is that prompts like "write me a login function" or "generate auth middleware" consistently produce code that would have been acceptable a decade ago. MD5 and SHA-1 for password storage. JWTs signed with the string "secret". AES in ECB mode. These are not exotic mistakes. They come up constantly, in every language, across every framework.
MD5 and SHA-1 for password storage
MD5 produces a 128-bit hash in microseconds. On a modern GPU, you can test roughly 200 billion MD5 hashes per second. A typical 8-character password using letters, numbers, and symbols has around 200 trillion possible values. At 200 billion attempts per second, that is under 17 minutes.
SHA-1 is faster than MD5 in some implementations and only marginally better in practice. Neither was designed for password storage. They were designed for integrity checking: fast is the whole point. For passwords, fast is the problem.
Here is what AI-generated code often looks like:
import crypto from 'crypto';
// Do not use this
function hashPassword(password: string): string {
return crypto.createHash('md5').update(password).digest('hex');
}
And here is a correct replacement using bcrypt:
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
bcrypt, Argon2, and scrypt in 2026
All three are acceptable. The choice between them is mostly about what your runtime supports and how much you care about memory-hardness.
bcrypt is the most portable. A cost factor of 12 is the minimum worth using today; 13 or 14 is better if your login latency budget allows it. Each increment roughly doubles the time. Aim for 200-400ms on your production hardware.
Argon2id won the Password Hashing Competition in 2015 and is now the OWASP recommendation. It gives you independent control over time cost, memory cost, and parallelism. For most web apps, m=19456 (19 MiB), t=2, p=1 is a sensible starting point.
scrypt sits between the two in terms of adoption. It is available in Node's built-in crypto module since v15, which matters if you want to avoid native dependencies.
The specific algorithm matters less than the decision to use one of them instead of SHA-256.
JWT HS256 with weak secrets
This one is almost funny, except that it ships to production constantly. A JWT signed with HS256 and the secret "secret" is not a signed token. It is a token that anyone can forge once they guess the secret, and guessing "secret" takes zero effort.
HS256 uses a symmetric secret. If that secret is short, dictionary-attackable, or hardcoded, the entire authentication system collapses. The fix is either to use a cryptographically random secret of at least 256 bits, or to switch to RS256.
RS256 uses a private key to sign and a public key to verify. The verification key can be published openly. The signing key never leaves your auth server. This is strictly better than HS256 for most architectures because even if your verification logic is compromised, an attacker cannot forge tokens without the private key.
import jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
const privateKey = readFileSync('./keys/private.pem');
const publicKey = readFileSync('./keys/public.pem');
export function signToken(payload: Record<string, unknown>): string {
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '1h',
issuer: 'api.yourapp.com',
});
}
export function verifyToken(token: string): Record<string, unknown> {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'api.yourapp.com',
}) as Record<string, unknown>;
}
Generate the keypair with: openssl genrsa -out private.pem 2048 && openssl rsa -in private.pem -pubout -out public.pem
AES-ECB: the mode that should not exist in new code
Electronic Codebook mode encrypts each block of plaintext independently. The same input block always produces the same output block. This means that patterns in plaintext survive into ciphertext. The famous demonstration is the encrypted Linux penguin image, where the silhouette remains clearly visible in the encrypted version.
For any structured data, user data, or anything with repeated patterns, ECB mode leaks information. It should not appear in new code at all.
AES-GCM is the correct choice. It is authenticated encryption: you get both confidentiality and integrity in one operation. A unique 96-bit IV per encryption is not optional.
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 12; // 96 bits, required for GCM
const TAG_LENGTH = 16;
export function encrypt(plaintext: string, key: Buffer): Buffer {
if (key.length !== KEY_LENGTH) throw new Error('Key must be 32 bytes');
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Prepend IV and auth tag so they travel with the ciphertext
return Buffer.concat([iv, tag, encrypted]);
}
export function decrypt(payload: Buffer, key: Buffer): string {
if (key.length !== KEY_LENGTH) throw new Error('Key must be 32 bytes');
const iv = payload.subarray(0, IV_LENGTH);
const tag = payload.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = payload.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final('utf8');
}
The auth tag verification in GCM means that if anyone tampers with the ciphertext, decryption will throw rather than silently returning garbage. That matters.
The underlying problem
None of this is the AI's fault in any satisfying sense. The model is doing exactly what it was trained to do: produce plausible code that matches the pattern of the prompt. The problem is that the corpus it learned from is full of insecure examples, and the model has no way to prefer secure ones without explicit training signals to that effect.
Reviewing AI-generated auth code means not trusting the parts that look fine. The crypto calls in particular warrant a second look every time, because they will look correct and may not be.
Seaworthy flags these patterns automatically.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.