Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

How Passwords Should Be Stored (and How Breaches Really Happen)

About Post

Assume your users table leaks tomorrow. Not "if". Assume it. Someone has a copy of every row, including the password column, and all the time in the world.

The only question that matters now is: what exactly did they get? A list of passwords, or a list of puzzles that are expensive to solve one by one?

That's what password storage is really about. Not keeping attackers out of the database (that's a different layer), but making sure that when they get in, the passwords are close to worthless.

Five ways to store a password, from worst to right

Let's walk up the ladder. Every rung below the last one still exists in production somewhere.

1. Plain text

The leak is the passwords. Game over, for your app and for every other site where those users reused the same password. If a "forgot password" feature can email you your old password, this is what it's doing.

2. Encrypted

Better-sounding, barely better in practice. Encryption is reversible by design, so the key has to live somewhere your app can reach it. An attacker who gets the database often gets the server too, and the key with it. Passwords never need to be decrypted, so they should never be encrypted.

3. A fast hash (MD5, SHA-1, SHA-256)

Now it's one-way: you store sha256(password) and compare hashes on login. The problem is speed. These algorithms were designed to be fast, and a single gaming GPU can try guesses at a staggering rate. Worse, the same password always produces the same hash, so attackers use precomputed tables, and every user with Password123 falls at once.

4. A fast hash with a salt

A salt is a random value stored next to each hash and mixed into it. Now two users with the same password get different hashes, and precomputed tables are useless. Good. But each guess is still cheap, so weak passwords still fall quickly, just one user at a time.

5. A slow, salted password hash (bcrypt or Argon2id)

This is the answer. Algorithms like bcrypt and Argon2id are deliberately slow and tunable. Checking one password at login takes a fraction of a second, which no user notices. Trying millions of guesses against every row becomes painfully expensive. Argon2id is also memory-hard, which makes it much less friendly to GPUs and custom hardware.

They handle the salt for you, too. The output string contains the algorithm, the cost settings, the salt and the hash all in one:

$hash = password_hash('correct horse battery staple', PASSWORD_ARGON2ID);
// $argon2id$v=19$m=65536,t=4,p=1$c2FsdC4uLg$...

password_verify($input, $hash); // true or false
StorageReversible?Same password, same value?Cost per guess
Plain textNot neededYesNone
EncryptedYes, with the keyUsuallyNone once the key leaks
Fast hashNoYesTiny
Salted fast hashNoNoTiny
bcrypt / Argon2idNoNoHigh, and tunable

In Laravel, the right thing is the default

If you use Hash::make() (or the hashed cast on the model), you're on rung five. Laravel uses bcrypt by default, and you can switch to Argon2id in config/hashing.php. The cost is controlled by BCRYPT_ROUNDS.

Two details worth knowing:

  • Rehash on login. When you raise the cost or change the algorithm, old hashes are still valid. Since Laravel 11, the framework rehashes a user's password automatically when they log in successfully, so your whole table upgrades over time without a migration.
  • bcrypt only reads the first 72 bytes. Anything longer is silently ignored. For normal passwords it doesn't matter, but if you allow very long passphrases, Argon2id doesn't have this limit.

How breaches actually turn into stolen accounts

Here's the uncomfortable part. Even with perfect hashing, most account takeovers don't come from cracking your database. They come from:

  • Credential stuffing. Attackers take email and password pairs leaked from some other site and try them on yours, automatically. Password reuse makes it work.
  • Phishing. The user types their password into a fake login page. No hash can help.
  • Weak, common passwords that fall to the first few thousand guesses no matter how they're stored.

So good storage is necessary, but the login form needs defences too.

Defence 1: rate limit the login

Slow down repeated attempts per account and per IP. Laravel's rate limiter makes this short:

$key = Str::lower($request->input('email')).'|'.$request->ip();

if (RateLimiter::tooManyAttempts($key, 5)) {
    $seconds = RateLimiter::availableIn($key);
    throw ValidationException::withMessages([
        'email' => "Too many attempts. Try again in {$seconds} seconds.",
    ]);
}

if (! Auth::attempt($request->only('email', 'password'))) {
    RateLimiter::hit($key, 60);
    throw ValidationException::withMessages(['email' => __('auth.failed')]);
}

RateLimiter::clear($key);

This is simplified. Credential stuffing usually comes from many IP addresses, so also watch failures per account and overall, and keep the error message the same whether the email exists or not.

Defence 2: reject passwords that are already leaked

A password that appears in public breach lists is a bad password, however complex it looks. Laravel's password rule can check this for you:

'password' => ['required', 'confirmed', Password::min(12)->uncompromised()],

uncompromised() uses the Have I Been Pwned range API with k-anonymity: only the first five characters of the password's SHA-1 hash leave your server, never the password or the full hash.

Defence 3: don't make the password the only thing

Multi-factor authentication turns a stolen password into a much smaller problem. For admin and staff accounts, it's not optional. For everyone else, offer it and make it easy. Passkeys go further still, because there is no shared secret to phish or reuse.

The rule: hash with bcrypt or Argon2id and never anything you wrote yourself. Then assume some passwords are already known to attackers, and design the login around that: rate limits, breach checks and MFA.

A quick checklist

  • Passwords go through Hash::make(), password_hash() or your framework's equivalent. Nothing else.
  • No MD5, SHA-anything or encryption for passwords, even "temporarily".
  • Cost settings reviewed occasionally, with rehash on login.
  • Login rate limited, with the same message for "wrong email" and "wrong password".
  • Known-breached passwords rejected at sign-up and on change.
  • MFA for anyone with admin powers.
  • Passwords never logged, including in request logs and error trackers.

That last one catches more teams than you'd think. When did you last search your logs for the word "password"?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close