skip to content

In Laravel, how do Hash::make() and Hash::check() store and verify a password, and why is Crypt::encryptString() the wrong tool for it?

level: juniorimportance: must knowfreq 72%

answer

  1. one-way versus reversible
  2. Hash::make returns a self-describing string
  3. salt and cost live inside the hash
  4. Hash::check(plain, hash) returns bool
  5. bcrypt driver, 12 rounds by default

basics

~20 s

Hash::make() turns a password into a one-way, salted bcrypt or Argon2 hash that you store; Hash::check() re-hashes the attempt and compares, returning true or false. Crypt is reversible, so anyone with APP_KEY could read every password.

solid answer

~40 s

`Hash::make($password)` asks the configured driver (`bcrypt` by default, 12 rounds) for a hash. The result is a self-describing string such as `$2y$12$...` that already contains the algorithm, the cost and a random salt, so you store just that one column. `Hash::check($plain, $hashed)` reads those parameters back out of the stored string, hashes the attempt the same way and returns a boolean; it returns `false` for a `null` or empty hash instead of throwing. Hashing is deliberately one-way and slow. `Crypt::encryptString()` is reversible by design: anyone who obtains `APP_KEY` along with a database dump can decrypt every password, and a login would need to decrypt passwords to compare them. In the skeleton the `User` model casts `password` to `hashed`, so assigning a plain password hashes it for you.

code

php · 12 lines
php
<?php

use Illuminate\Support\Facades\Hash;

$hash = Hash::make('correct horse battery staple');
// '$2y$12$...' : algorithm, cost and salt live inside the string

Hash::check('correct horse battery staple', $hash); // true
Hash::check('wrong guess', $hash);                   // false
Hash::check('anything', null);                       // false, no exception

Hash::make('same') === Hash::make('same');           // false: new salt each time

go deeper

for a junior

Know Hash::make() to store and Hash::check() to verify, and that the result is one-way, unlike Crypt which can be decrypted.

for a middle

Explain what the hash string carries (algorithm, cost, salt) and why Hash::check() can verify old hashes after the cost changes.

for a senior

Spot code that bypasses Hash::check(), encrypts secrets that only need comparing, or leaks plain passwords into logs, and fix the storage model.

for a principal

Decide which secrets in a system are compared versus read back, and set a rule that maps each class to hashing or encryption.

## Two different jobs Laravel ships two facades that are easy to confuse because both turn readable text into unreadable text: | | `Hash` | `Crypt` | |---|---|---| | Direction | one-way: no method returns the original | two-way: `decryptString()` returns it | | Secret involved | none; a random salt is stored inside the hash | `APP_KEY` | | Speed | deliberately slow (tunable cost) | fast | | Right use | passwords and other values you only need to *compare* | values you must *read back*, such as a third-party API token | A password only ever has to be compared, never read, so it belongs to `Hash`. ## What Hash::make produces `Hash::make($value)` hands the value to the default driver named by `hashing.driver` (`HASH_DRIVER`, default `bcrypt`). The `bcrypt` driver calls PHP's password API with the cost from `hashing.bcrypt.rounds` (`BCRYPT_ROUNDS`, default 12). The result looks like this: ```console $2y$12$Q3m1eVh1Jc5b0uZ... (60 characters) | | | | | +-- salt and hash | +----- cost 12 (2^12 rounds of work) +--------- algorithm identifier (bcrypt) ``` Everything needed to verify later travels in that single string: - the **algorithm** identifier; - the **cost** (work factor); - a fresh random **salt**, which is why hashing the same password twice gives two different strings. So a `users.password` column is all you need; there is no separate salt column and nothing to configure per user. ## How Hash::check verifies `Hash::check($plain, $hashed)` does not "decrypt" anything. It: 1. returns `false` straight away when `$hashed` is `null` or an empty string; 2. with the config default `HASH_VERIFY=true`, confirms the stored hash uses the configured algorithm, and throws a `RuntimeException` if not; 3. re-hashes `$plain` with the salt and cost read from `$hashed` and compares the results. Because the comparison uses the stored parameters, older hashes made with a lower cost still verify after you raise `BCRYPT_ROUNDS`. ## Why encryption is the wrong tool Encrypting passwords with `Crypt::encryptString()` fails on several counts: - **One secret unlocks everything.** `APP_KEY` sits in the environment of every web server and worker; a leak of the key plus a database dump reveals every password in plain text. - **It is fast.** Encryption is built for throughput, so it adds no brute-force resistance. - **Equal inputs are hidden, but so is nothing else.** Once decrypted, users who reused a password elsewhere are exposed immediately. - **The login code would have to decrypt** each stored password to compare it, which means plain passwords exist in application memory on every login. Hashing removes the shared secret from the picture: even the application cannot recover a password, it can only confirm a guess. ## How it looks in the skeleton The skeleton's `User` model declares `'password' => 'hashed'` in its `casts()` method, so `$user->password = $request->password` stores a hash automatically, and the `Auth` guards call `Hash::check` for you during login. You call `Hash::make()` and `Hash::check()` directly when you handle a password outside those paths: confirming the current password before a change, verifying a PIN or an API secret stored as a hash, or writing a custom user provider. ## Where the driver and cost come from You rarely configure anything to get this behaviour. The skeleton ships no `config/hashing.php`; the framework's copy is merged in, and two environment variables cover the common changes: - `HASH_DRIVER` picks `bcrypt` (default), `argon` or `argon2id`; - `BCRYPT_ROUNDS` sets the bcrypt cost; `.env.example` sets it to 12 and the skeleton's `phpunit.xml` lowers it to 4 so test suites that create many users stay fast. Per-call options exist too: `Hash::make($value, ['rounds' => 12])` for bcrypt, or `memory`, `time` and `threads` for Argon. The docs note that the defaults suit most applications, so overriding them per call is unusual. ## Common slips - Comparing `Hash::make($input) === $user->password`: always false, because every call uses a new salt. - Storing the password in a column too short for the hash. - Logging the request payload that still contains the plain password.

  • Why does Hash::make('secret') === $user->password never match even for the correct password?
    Each `Hash::make()` call draws a new random salt, so the same password produces a different string every time. Only `Hash::check()` can compare, because it reuses the salt and cost stored inside the existing hash before comparing.
  • When would you still use Crypt instead of Hash for a secret in a Laravel app?
    When the application must send the original value somewhere, for example an OAuth refresh token or a third-party API key it presents on every call. Those must be read back, so `Crypt::encryptString()` fits. A password, a PIN or a recovery code only ever needs comparing, so it is hashed.

saying these in an interview costs you the question

  • Passwords should be encrypted with APP_KEY so support can recover them.
  • You need a separate salt column next to the password hash.
  • Hash::check() decrypts the stored hash and compares plain strings.
  • Hashing the same password twice must give the same string.
  • Hash::check() throws an exception when the stored hash is null.