All posts
4 min read

Password Hashing Explained: Why You Never Store Passwords

A well-built app doesn't know your password — it stores a hash. What hashing is, why fast hashes like MD5 and SHA-256 are wrong for passwords, what salts do, why bcrypt and Argon2 exist, and how to check that your AI-built app got it right.

getting startedsecuritydatabasesbeginner

Here's something that surprises people: a properly built app doesn't know your password. It can check whether what you type is correct, but it can't tell you what your password is — which is why "forgot password" sends a reset link instead of your old password.

It does this with password hashing. If your app has email-and-password login, getting this right is non-negotiable.

Why not just store the password?

Because databases leak. Backups get misplaced, servers get breached, a developer's laptop gets stolen, a misconfigured database is left open to the internet. When that happens:

  • If passwords are stored as plain text, the attacker has every user's password.
  • Most people reuse passwords. So they now have access to your users' email, banking, and everything else.

Your job is to make sure that even if your database is stolen, the passwords in it are useless.

What a hash is

A hash function turns any input into a fixed-looking scramble, in a way that can't be reversed:

hash("sunflower42")  →  "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$Kf8x..."
  • The same input always gives the same output.
  • A tiny change in input gives a completely different output.
  • You can't get from the output back to the input.

So at sign-up, you store the hash. At login, you hash what the user typed and compare. Match → correct password. You never need the original.

Salt: why identical passwords look different

If two users both pick sunflower42, a plain hash gives them the same result — and an attacker with a list of pre-computed hashes for common passwords could look them up instantly.

A salt fixes this: a random value, unique per user, mixed in before hashing and stored alongside the hash. Now identical passwords produce different hashes, and pre-computed lists are worthless. Modern password-hashing functions generate and store the salt for you — it's part of that long string above.

Why fast hashes are wrong for passwords

This is the part AI-generated code most often gets wrong.

General-purpose hash functions like MD5, SHA-1, and SHA-256 are designed to be fast — useful for checking file integrity, terrible for passwords. With a stolen database, an attacker can try billions of guesses per second against fast hashes on ordinary graphics cards. Most real passwords fall quickly.

Password hashing functions are designed to be deliberately slow and memory-hungry, so each guess costs real time and hardware:

Algorithm Status
Argon2id The current recommendation from OWASP for new apps
scrypt Good
bcrypt Good and extremely widely used; fine for most apps
PBKDF2 Acceptable where required by standards, with a high iteration count
MD5, SHA-1, SHA-256 (alone) Not for passwords
Encryption (reversible) Not for passwords — if it can be decrypted, the key can be stolen too

Taking a quarter of a second to check one login is unnoticeable to a user and crippling for an attacker trying billions.

What it looks like in code

You should never write this yourself — use a well-maintained library:

import bcrypt from "bcrypt"

// Sign-up
const hash = await bcrypt.hash(password, 12)   // 12 = cost factor
await db.users.insert({ email, passwordHash: hash })

// Login
const ok = await bcrypt.compare(attempt, user.passwordHash)

Better still: use an authentication service or library that does all of this and handles sessions, resets, and rate limiting. (How to Add Login to an AI-Built App.)

How to check your AI-built app

If your app manages its own passwords, look (or ask your AI tool to look) at:

  • The database column. Values should look like $2b$12$… (bcrypt) or $argon2id$… (Argon2). If you can read passwords, or they're 32 or 64 characters of hex (likely MD5 or SHA-256), it's wrong.
  • The code. Search for md5, sha1, sha256, createHash near anything password-related.
  • Logs. Passwords must never be logged — check request logging doesn't capture login forms. (Structured Logging.)
  • Password reset. Should send a single-use, expiring link, never the password.
  • Rate limiting on login, so attackers can't guess online either. (What Is Rate Limiting?.)

If any fail, switch to a proper library or login service. Existing users can be migrated: re-hash each password properly the next time that user logs in.

Beyond hashing

  • Allow long passwords and paste, so password managers work.
  • Check new passwords against known-breached lists rather than enforcing odd character rules.
  • Offer two-factor authentication or passkeys where it matters.

EasySpawn gives Claude Code a real backend and database to work with, so authentication can use proper server-side libraries instead of anything that runs in the browser. See how it works for AI-built apps or join the waitlist.

Related: A Security Checklist for Vibe-Coded Apps · What Is a JWT? · SQL Injection Explained

Keep reading