# Password Security in Laravel: Hashing, Rehashing, and Breach Checks

> Hashing a password is the easy part. Knowing why bcrypt is meant to be slow, when Laravel quietly rehashes on login, and how to reject passwords that already sit in a breach corpus is what separates a secure auth stack from one that only looks secure.

**Author:** Matt King | **Published:** July 7, 2026 | **Category:** Security

---

Your Laravel app hashes passwords. The login form works, registration works, and `Hash::make()` is sitting there in the controller doing its job. You might think passwords are handled, but hashing is only the start. The interesting questions are why the hash is slow, what happens when you want to make it slower, and how you stop a user choosing a password that an attacker already has on a list.

This post covers the parts of Laravel password handling that bite teams later: the driver and work factor in `config/hashing.php`, the constant-time check you should not reimplement, automatic rehashing on login, breach checks with the `Password` rule, and the surprisingly common ways a plaintext password ends up in a log file. Everything here is accurate for Laravel 11 and 12.

---

## The default is bcrypt, and that is fine

Out of the box, Laravel hashes passwords with bcrypt. The choice lives in `config/hashing.php`, and the file is short enough to read in full.

```php
// config/hashing.php

return [
    'driver' => 'bcrypt',

    'bcrypt' => [
        'rounds' => env('BCRYPT_ROUNDS', 12),
        'verify' => true,
    ],

    'argon' => [
        'memory' => 65536,
        'threads' => 1,
        'time' => 4,
        'verify' => true,
    ],
];
```

The `driver` key picks the algorithm. `bcrypt` is the default; the alternatives are `argon` and `argon2id`. The block underneath each driver carries its work factor, which is the dial that controls how expensive a single hash is to compute.

For bcrypt that dial is `rounds`. It is a logarithmic cost: rounds of 12 means 2^12 iterations of the underlying key-derivation step, and bumping it to 13 doubles the work. The default of 12 is a sensible 2026 floor. For Argon2id the cost is spread across three numbers: `memory` in kibibytes, `time` as the number of passes, and `threads` for parallelism. Argon's headline feature is that it is memory-hard, which is what makes it resistant to attackers who throw GPUs at the problem, since GPUs are starved for memory bandwidth rather than raw compute.

---

## Why slow is the point

It feels wrong to deliberately make code slow. With password hashing it is the whole design goal, and the reasoning is worth being precise about.

A hash function for passwords is never under attack while it runs on your server. It is under attack after your database leaks. Once an attacker has the table of hashes, they crack offline, on their own hardware, with no rate limiting and no lockouts to slow them down. The only thing standing between them and your users' plaintext is how long each guess takes.

A fast general-purpose hash like MD5 or SHA-1 can be computed billions of times per second on commodity GPUs. Against that speed, every password from a common-words list falls in seconds. Bcrypt at 12 rounds takes a meaningful fraction of a second per guess, which sounds trivial but collapses the attacker's throughput from billions per second to thousands. That difference is the entire game. It turns "every weak password cracked by lunchtime" into "the strong ones may never crack at all".

The cost factor is your lever to keep pace with hardware. Hardware gets faster every year; your `rounds` value should creep up to match. A useful rule of thumb is to set the cost so a single hash takes somewhere around 250 to 500 milliseconds on your production hardware. Slow enough to punish an offline attacker, fast enough that a login does not feel sluggish.

---

## Make, check, and the comparison you should not write

Two methods cover almost everything you do day to day.

```php
// Hashing a password before storing it
$user->password = Hash::make($request->validated('password'));

// Verifying an attempt at login
if (Hash::check($request->input('password'), $user->password)) {
    // credentials match
}
```

`Hash::make()` produces a self-describing hash string. The bcrypt output starts with `$2y$12$` and embeds the algorithm, the cost, and the salt, all in one string. You never store a salt separately, because it is already in there. That is also how `Hash::check()` knows which algorithm and cost to verify against.

The part people get wrong is the comparison. `Hash::check()` is already constant-time. Under the hood it calls PHP's `password_verify()`, which does not short-circuit on the first mismatched byte, so it leaks no timing signal about how close a guess was. You do not need `hash_equals()` on top of it, and you definitely should not compare hashes with `==`.

```php
// BAD - reintroduces a timing side-channel and is just wrong
if (Hash::make($request->password) === $user->password) {
    // bcrypt salts each hash, so this NEVER matches anyway
}

// BAD - storing or comparing with a fast hash defeats the purpose
if (md5($request->password) === $user->password_md5) {
    // attackers crack this offline in seconds
}

// GOOD - constant-time, salt-aware, algorithm-aware
if (Hash::check($request->password, $user->password)) {
    // ...
}
```

The first bad example fails on a subtler point too: bcrypt generates a fresh random salt on every call, so hashing the same password twice produces two different strings. A direct equality check never matches even with the correct password. `Hash::check()` exists precisely because verification is not string equality.

And the rules that should never need restating but always do: never store a plaintext password, and never reach for `md5()`, `sha1()`, or `crypt()` to hash one. Those are fast hashes built for checksums and file integrity, not for resisting an offline cracker.

---

## Comparing the options honestly

Here is how the realistic choices stack up against the broken ones.

| Algorithm | Salted | Slow by design | Memory-hard | Verdict |
| --- | --- | --- | --- | --- |
| **Argon2id** | Yes, automatic | Yes, tunable | Yes | Strongest modern choice; needs libsodium/libargon2 |
| **bcrypt** | Yes, automatic | Yes, tunable | No | Excellent and the Laravel default; safe everywhere |
| **crypt()** raw | Depends on caller | Depends on caller | No | Footgun; easy to misuse, no reason to touch it |
| **sha1 / sha256** | No | No | No | Fast hash, crackable offline; never for passwords |
| **md5** | No | No | No | Broken; trivially cracked, do not use |
| **plaintext** | No | No | No | A breach hands attackers every account instantly |

Between bcrypt and Argon2id, you are not choosing between safe and unsafe. You are choosing between two strong options. Argon2id edges ahead because its memory-hardness blunts GPU and ASIC attacks that bcrypt is more exposed to. Bcrypt wins on availability and a longer track record of scrutiny. If your PHP build supports Argon2id and you have tuned the parameters against your hardware, it is the better pick. If you are unsure, the default bcrypt is not a compromise you need to apologise for.

---

## Automatic rehashing: raising the cost without resets

This is the feature that makes raising your work factor painless, and most teams do not know it exists.

Say you shipped two years ago with `rounds` at 10, and you now want 12. Existing users have hashes computed at the old cost. You do not want to email everyone and force a reset. You do not have to.

When you bump the cost in config, Laravel detects out-of-date hashes during login. `Auth::attempt()` (and the framework's guard internals) calls `Hash::needsRehash()` after a successful verification. If the stored hash is below the current cost, the framework rehashes the plaintext the user just supplied at login and writes the upgraded hash back to the record. The user logs in normally and notices nothing.

```php
// How the framework decides, illustrated manually

if (Hash::check($plain, $user->password)) {
    if (Hash::needsRehash($user->password)) {
        $user->forceFill([
            'password' => Hash::make($plain),
        ])->save();
    }

    // proceed with login
}
```

You rarely write this by hand, because `Auth::attempt()` already does it for you when you use the standard guard. The thing to internalise is the consequence: bumping `rounds` in `config/hashing.php` is a safe, gradual migration. Active users drift up to the new cost over a few weeks of normal logins, and dormant accounts get upgraded the first time they come back.

```php
// config/hashing.php  — raise the floor and let logins do the migration

'bcrypt' => [
    'rounds' => env('BCRYPT_ROUNDS', 13),
    'verify' => true,
],
```

The same mechanism handles switching drivers. Move from bcrypt to argon2id, and existing bcrypt hashes still verify (the hash string is self-describing), while each login quietly re-hashes the user onto Argon2id. No big-bang migration, no forced resets.

---

## Rejecting passwords that are already breached

A strong hash protects a weak password badly. If a user picks `password1`, no amount of bcrypt rounds saves them, because that string is the first guess in every dictionary attack. The fix is to stop the weak password getting in at registration.

Laravel's `Password` rule object does the heavy lifting.

```php
// In a FormRequest or controller validation

use Illuminate\Validation\Rules\Password;

$request->validate([
    'password' => [
        'required',
        'confirmed',
        Password::min(12)
            ->mixedCase()
            ->numbers()
            ->uncompromised(),
    ],
]);
```

`min(12)` sets a length floor, `mixedCase()` and `numbers()` enforce some character variety, and `uncompromised()` is the one that earns its keep. It checks the candidate against the Have I Been Pwned breach corpus, which holds hundreds of millions of passwords pulled from real leaks.

The clever part is how it checks without sending the password anywhere. It uses **k-anonymity** through the HIBP range API. Laravel hashes the candidate with SHA-1, takes the first five characters of that hash, and sends only those five characters to the API. The service replies with every breached hash suffix sharing that prefix, often several hundred, and Laravel matches against the list locally. The full password and its full hash never leave your server. The API operator sees a five-character prefix that maps to thousands of possible passwords and cannot tell which one you asked about.

Rather than repeat that rule in every form, set it once as the application default in a service provider.

```php
// app/Providers/AppServiceProvider.php

use Illuminate\Validation\Rules\Password;

public function boot(): void
{
    Password::defaults(function () {
        $rule = Password::min(12)->mixedCase()->numbers();

        return $this->app->isProduction()
            ? $rule->uncompromised()
            : $rule;
    });
}
```

Then any validation just references the named default.

```php
$request->validate([
    'password' => ['required', 'confirmed', Password::defaults()],
]);
```

Wrapping `uncompromised()` in a production check is deliberate. The range API call adds latency and a network dependency you usually do not want hammering during a test suite, so skipping it outside production keeps your tests fast and offline while still protecting real users. Strong passwords are one layer; pair them with [two-factor authentication using Fortify](/blog/laravel-two-factor-authentication-fortify) and you have closed the two most common account-takeover routes.

---

## Stop leaking the password you just protected

You can hash perfectly and still hand the plaintext to an attacker, because a password leaks before it is ever hashed. It arrives in plaintext on the request, and that plaintext has a habit of ending up in places it should not.

**Logs and stack traces are the worst offender.** A logged `$request->all()`, or an exception that captures request input, can dump the raw password into your log files or your error tracker. Anyone with log access then reads it directly.

```php
// BAD - the password is right there in the log
Log::info('Login attempt', $request->all());

// GOOD - log only what you need, never the credential
Log::info('Login attempt', ['email' => $request->input('email')]);
```

Laravel already guards the common cases. The `$dontFlash` property on the exception handler strips `password` and `password_confirmation` before flashing input back to the session on a validation error, which is why a failed registration does not echo the password into the form. Keep your own field names on that list if you use non-standard ones.

```php
// app/Exceptions/Handler.php  (or bootstrap/app.php in Laravel 11/12)

protected $dontFlash = [
    'current_password',
    'password',
    'password_confirmation',
];
```

**Queues and Telescope are the quiet leaks.** If a job carries a password in its payload, that payload is serialised into the `jobs` table and, on failure, the `failed_jobs` table, sitting there in plaintext until someone prunes it. The fix is to never put a raw password into a queued job; pass an identifier and re-fetch, or hash before dispatch. Telescope is the same risk at a different layer: it records request payloads, and left unfiltered in production it becomes a searchable archive of every password that flowed through your app. Filter sensitive parameters out, or do not run Telescope in production at all.

Treating the request payload as sensitive end to end is the kind of thing a configuration review catches, and it sits squarely inside the broader picture the [OWASP Top 10 for Laravel](/blog/owasp-top-10-laravel) lays out under cryptographic and logging failures.

---

## Do this today

Pick the two changes with the highest payoff: set `Password::defaults()` in your service provider with `uncompromised()` enabled in production, and bump your bcrypt `rounds` so a single hash lands around 250 to 500 milliseconds on your hardware. Automatic rehashing migrates your existing users on their next login, so there is no reset to coordinate.

Not sure whether your current hashing config and request logging are leaking somewhere you cannot see? [Run a free StackShield scan](/free-scan) and we will flag the weak spots in your Laravel setup.

---

## Frequently Asked Questions

### Does Laravel use bcrypt or Argon2 for passwords by default?

Laravel hashes passwords with bcrypt out of the box. The driver is set in config/hashing.php under the driver key, and bcrypt is the default value there. Argon2id is fully supported and is generally considered the stronger modern choice, but it requires the libsodium or libargon2 support to be available in your PHP build. Switching is a one-line config change plus tuning the work factors, and thanks to automatic rehashing you can migrate users gradually rather than all at once.

### Why is password hashing supposed to be slow?

A slow hash is the entire defence against offline cracking. If your database leaks, an attacker runs guesses against the stored hashes on their own hardware, with no rate limiting to stop them. A fast hash like MD5 lets them try billions of candidates a second, so common passwords fall almost instantly. Bcrypt and Argon2id are deliberately expensive, which caps how many guesses per second are possible and turns a few hours of cracking into months or years. The cost factor lets you keep raising that bar as hardware gets faster.

### Do I need hash_equals when comparing passwords in Laravel?

No. Hash::check already performs a constant-time comparison internally through PHP's password_verify, so it does not leak timing information about how many characters matched. Calling hash_equals yourself on top of it adds nothing and usually signals a misunderstanding of how the verification works. The one rule is to always pass the plaintext attempt and the stored hash to Hash::check rather than rolling your own string comparison. The moment you write your own == or === comparison on a hash, you reintroduce the timing problem.

### How does Laravel rehash passwords when I raise the work factor?

When you increase the bcrypt rounds or change the Argon parameters in config/hashing.php, existing hashes are not invalidated. On the next successful login, Auth::attempt checks the stored hash against the current settings with Hash::needsRehash, and if the hash is now under-strength it transparently rehashes the plaintext the user just supplied and updates the record. The user never notices and never has to reset anything. Over a few weeks of normal logins, your active users drift up to the new cost without a forced migration.

### How does the uncompromised password rule avoid leaking the password?

The uncompromised rule uses the Have I Been Pwned range API, which is built on k-anonymity. Laravel hashes the candidate password with SHA-1, then sends only the first five characters of that hash to the API. The service returns every breached hash suffix that shares those five characters, often hundreds of them, and Laravel checks for a match locally. The full password and its full hash never leave your server, so the API operator cannot tell which password you were checking.

