Skip to content
academia.sh

Lesson 14 / 15

Password Storage

Measuring that a password is stored as a hash, that a different salt gives a different hash for the same password, the cost parameter, and rehashing at login; the default cost is in the environment's hands.

Published Updated

In this lesson

  • Hashes and verifies a password with password_hash() and password_verify(), and measures the prefix of the hash without printing the whole of it
  • Shows with the party measurement who determines the result of the cost parameter
  • Rehashes an old-cost hash at login with password_needs_rehash()
Contents

The Request Forgery Protection lesson had the server determine who a request came from. If the identity itself rests on a password, the question of how the password is stored remains: who determines the stored value and the setting that produces it? This lesson measures the built-in way of storing a password and whether one of its settings is in the environment’s hands.

SE5. In this lesson the password and the hash are used only for storage and verification. Guessing passwords, cracking hashes and reversing a stored hash are not modeled. The whole of a hash is printed nowhere; only the prefix, the length and the verification result are printed.

Hash and Verification

A password is not stored as it is. A hash is a one-way digest of the password: the same password gives the same digest, but the password cannot be produced from the digest. For passwords this is done with password_hash(). The function generates a random salt on every call and writes it into the hash; so two hashes of the same password differ from each other. password_verify() takes the password and the hash, reads the salt and the setting inside the hash, and verifies. The $2y$12$ prefix at the start of the hash carries the algorithm (2y, bcrypt) and the cost parameter.

The file below defines the functions used in the lesson: producing a hash, taking the prefix, and verifying at login and rehashing if needed.

<?php
// password.php — hashing, prefix and rehashing at login
declare(strict_types=1);

function hashPassword(string $password, ?int $cost = null): string {
    if ($cost === null) {
        return password_hash($password, PASSWORD_BCRYPT);
    }
    return password_hash($password, PASSWORD_BCRYPT, ['cost' => $cost]);
}

function prefix(string $hash): string {
    return substr($hash, 0, 7);
}

function logIn(string $password, string $storedHash, int $targetCost): array {
    if (!password_verify($password, $storedHash)) {
        return ['succeeded' => false, 'hash' => $storedHash];
    }
    if (password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => $targetCost])) {
        $storedHash = hashPassword($password, $targetCost);
    }
    return ['succeeded' => true, 'hash' => $storedHash];
}

The first measurement hashes a password; verifies a correct and a wrong password; hashes the same password twice and checks whether the hashes differ and whether both are verified.

<?php
// hash.php — hash, prefix and verification
declare(strict_types=1);
require 'password.php';

$hash = hashPassword('example-password');
$second = hashPassword('example-password');
echo 'prefix ', prefix($hash), ' · length ', strlen($hash), "\n";
echo 'correct password ', var_export(password_verify('example-password', $hash), true), "\n";
echo 'wrong password ', var_export(password_verify('other-password', $hash), true), "\n";
echo 'two hashes ', $hash === $second ? 'same' : 'different', "\n";
echo 'second hash verifies ', var_export(password_verify('example-password', $second), true), "\n";
prefix $2y$12$ · length 60
correct password true
wrong password false
two hashes different
second hash verifies true

The hash is 60 characters and starts with $2y$12$: bcrypt, cost 12. The correct password was verified, the wrong one was not. The two hashes of the same password differ, because a new salt was written on every call; even so, the second one was also verified with the same password. Verification does not compare hashes; it rehashes the password with the salt inside the hash and compares the result. The whole of the hash was not printed; the prefix and the length are enough for the measurement.

Party: Who Sets the Cost

The cost parameter determines how much work producing a hash requires. If it is not given explicitly, the runtime’s default is used; this default depends on the PHP version (10 before 8.4, 12 after). The same code can produce a different prefix in another environment. For the measurement, the environment’s deviation is the default cost being 10. The decision order is client, third party, environment; if none could change it, the server.

<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}

The result is the prefix of the produced hash. The client’s deviation is another password.

<?php
// cost.php — the party of the hash prefix
declare(strict_types=1);
require 'party.php';
require 'password.php';

function defaultPrefix(string $password, int $cost): string {
    return prefix(hashPassword($password, $cost));
}

partyReport(
    'default cost',
    fn(array $d) => defaultPrefix($d['client'] ?? 'example-password', $d['environment'] ?? PASSWORD_BCRYPT_DEFAULT_COST),
    ['client' => 'other-password', 'environment' => 10]
);
default cost → environment
    client could not change · environment changed

The owner of the prefix is the environment. The client’s password did not change any character of the prefix: the password determines the content of the hash, not its setting. When the default cost became 10 instead of 12, the prefix became $2y$10$ instead of $2y$12$. Because the cost is not written in the code, the setting is determined by the version of the runtime; the same code silently produces a hash with another cost in another deployment.

Rehashing at Login

The cost is raised over time, but the stored hashes stay at the old cost. password_needs_rehash() says whether a hash fits the target setting. The plain-text password is available only at login, when the user types it, so rehashing is done there too. The measurement below compares a hash with cost 10 against a target cost of 12, then tries a login with a correct and a wrong password.

<?php
// login.php — rehashing at login
declare(strict_types=1);
require 'password.php';

$old = hashPassword('example-password', 10);
echo 'old prefix ', prefix($old), "\n";
echo 'rehash needed ', var_export(password_needs_rehash($old, PASSWORD_BCRYPT, ['cost' => 12]), true), "\n";

$correct = logIn('example-password', $old, 12);
echo 'correct password ', var_export($correct['succeeded'], true), ' · prefix ', prefix($correct['hash']), "\n";
$wrong = logIn('other-password', $old, 12);
echo 'wrong password ', var_export($wrong['succeeded'], true), ' · prefix ', prefix($wrong['hash']), "\n";
old prefix $2y$10$
rehash needed true
correct password true · prefix $2y$12$
wrong password false · prefix $2y$10$

The hash with cost 10 gave true from password_needs_rehash() for the target 12. At a login with the correct password, the hash was produced again and the prefix became $2y$12$. A login with the wrong password failed and the stored hash was not touched: the prefix stayed $2y$10$. Doing the rehashing at login is not a preference but a necessity, because the plain-text password is available at no other moment.

The Bounding Measurement

If the cost is given explicitly, the default is not read. The code writing hashPassword($password, 12) removes the environment’s default from the result. The same party measurement is repeated here with the cost fixed; the environment’s deviation (10) is the same.

<?php
// explicit.php — the party when the cost is given explicitly
declare(strict_types=1);
require 'party.php';
require 'password.php';

function explicitPrefix(string $password): string {
    return prefix(hashPassword($password, 12));
}

partyReport(
    'explicit cost',
    fn(array $d) => explicitPrefix($d['client'] ?? 'example-password'),
    ['client' => 'other-password', 'environment' => 10]
);
explicit cost → server
    client could not change · environment could not change

When the cost is written in the code as 12, the environment’s default does not enter the result: the environment’s deviation (10) changed nothing in the prefix. The owner is the server. This is not the server increasing the number of settings, but the environment no longer being able to determine anything; to raise the cost, the code is changed, while password_needs_rehash() carries old hashes to the new setting at login.

Summary

  • The measured class of this lesson is the environment under the default cost and the server under the explicit cost: the cost part of the prefix was determined by the runtime’s default in the first and by the number written in the code in the second.
  • password_hash() generates a new salt on every call; two hashes of the same password differ and both are verified with password_verify(). The hash is 60 characters, and its prefix carries the algorithm and the cost.
  • A different password did not change the result of the prefix: the password determines the content of the hash, not its setting.
  • password_needs_rehash() catches an old-cost hash; rehashing is done at the only place the plain-text password is available, at a correct login. A wrong password does not change the hash.

Next Step

Storing the password is a part of an authentication flow. When a password, a session, a token and an authorization work together, who determines the identity of the request? The Authentication Flows lesson takes this up together with the findings of the whole security topic.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close