142

Recently I have been trying to implement my own security on a log in script I stumbled upon on the internet. After struggling of trying to learn how to make my own script to generate a salt for each user, I stumbled upon password_hash.

From what I understand (based off of the reading on this page), salt is already generated in the row when you use password_hash. Is this true?

Another question I had was, wouldn't it be smart to have 2 salts? One directly in the file and one in the DB? That way, if someone compromises your salt in the DB, you still have the one directly in the file? I read on here that storing salts is never a smart idea, but it always confused me what people meant by that.

Machavity
  • 30,841
  • 27
  • 92
  • 100
Josh Potter
  • 1,629
  • 2
  • 13
  • 11
  • 9
    No. Let the function take care of the salt. Double salting will cause you trouble and there's no need for it. – Funk Forty Niner May 16 '15 at 18:43
  • As @martinstoeckli mentions in their answer, what's being described here is known as a "pepper" and is often recommended these days. You were a trailblazer, Josh! :D – JoLoCo Feb 03 '22 at 17:55

4 Answers4

255

Using password_hash is the recommended way to store passwords. Don't separate them to DB and files.

Let's say we have the following input:

$password = $_POST['password'];

You first hash the password by doing this:

$hashed_password = password_hash($password, PASSWORD_DEFAULT);

Then see the output:

var_dump($hashed_password);

As you can see it's hashed. (I assume you did those steps).

Now you store this hashed password in your database, ensuring your password column is large enough to hold the hashed value (at least 60 characters or longer). When a user asks to log them in, you check the password input with this hash value in the database, by doing this:

// Query the database for username and password
// ...

if(password_verify($password, $hashed_password)) {
    // If the password inputs matched the hashed password in the database
    // Do something, you know... log them in.
} 

// Else, Redirect them back to the login page.

Official Reference

Dharman
  • 30,962
  • 25
  • 85
  • 135
Akar
  • 5,075
  • 2
  • 25
  • 39
  • 4
    Ok, I just tried this and it worked. I doubted the function because it seemed almost too easy. How long do you recommend I make the length of my varchar? 225? – Josh Potter May 16 '15 at 19:11
  • 4
    This is already in the manuals http://php.net/manual/en/function.password-hash.php --- http://php.net/manual/en/function.password-verify.php that the OP probably didn't read or understood. This question's been asked more often than none. – Funk Forty Niner May 16 '15 at 19:16
  • That's a different page. – Josh Potter May 16 '15 at 19:25
  • @JoshPotter different from what? plus, noticed they haven't responded to your 2nd question. they're probably expecting you to find out yourself, or they don't know. – Funk Forty Niner May 16 '15 at 19:32
  • 21
    @FunkFortyNiner, b/c Josh asked the question, I found it, 2 years later, and it helped me. That's the point of SO. That manual is about as clear as mud. – toddmo Apr 23 '18 at 01:22
  • 2
    As for the length, from the PHP manual on password_hash, there's a comment in an example -- "Beware that DEFAULT may change over time, so you would want to prepare by allowing your storage to expand past 60 characters (255 would be good)" – Sheamus Jun 06 '20 at 03:37
  • 4
    @toddmo : To second your comment, I've just come to this question in June 2020 and the discussion has saved me hours of frustration. I, too, find the PHP manual about as clear as mud most of the time. – Thomas Murphy Jun 10 '20 at 12:25
33

Yes you understood it correctly, the function password_hash() will generate a salt on its own, and includes it in the resulting hash-value. Storing the salt in the database is absolutely correct, it does its job even if known.

// Hash a new password for storing in the database.
// The function automatically generates a cryptographically safe salt.
$hashToStoreInDb = password_hash($_POST['password'], PASSWORD_DEFAULT);

// Check if the hash of the entered login password, matches the stored hash.
// The salt and the cost factor will be extracted from $existingHashFromDb.
$isPasswordCorrect = password_verify($_POST['password'], $existingHashFromDb);

The second salt you mentioned (the one stored in a file), is actually a pepper or a server side key. If you add it before hashing (like the salt), then you add a pepper. There is a better way though, you could first calculate the hash, and afterwards encrypt (two-way) the hash with a server-side key. This gives you the possibility to change the key when necessary.

In contrast to the salt, this key should be kept secret. People often mix it up and try to hide the salt, but it is better to let the salt do its job and add the secret with a key.

martinstoeckli
  • 23,430
  • 6
  • 56
  • 87
9

Yes, it's true. Why do you doubt the php faq on the function? :)

The result of running password_hash() has has four parts:

  1. the algorithm used
  2. parameters
  3. salt
  4. actual password hash

So as you can see, the hash is a part of it.

Sure, you could have an additional salt for an added layer of security, but I honestly think that's overkill in a regular php application. The default bcrypt algorithm is good, and the optional blowfish one is arguably even better.

Joel Hinz
  • 24,719
  • 6
  • 62
  • 75
  • 2
    BCrypt is a _hashing_ function, while Blowfish is an algorithm for _encryption_. BCrypt originates from the Blowfish algorithm though. – martinstoeckli May 16 '15 at 18:43
8

There is a distinct lack of discussion on backwards and forwards compatibility that is built in to PHP's password functions. Notably:

  1. Backwards Compatibility: The password functions are essentially a well-written wrapper around crypt(), and are inherently backwards-compatible with crypt()-format hashes, even if they use obsolete and/or insecure hash algorithms.
  2. Forwards Compatibilty: Inserting password_needs_rehash() and a bit of logic into your authentication workflow can keep you your hashes up to date with current and future algorithms with potentially zero future changes to the workflow. Note: Any string that does not match the specified algorithm will be flagged for needing a rehash, including non-crypt-compatible hashes.

Eg:

class FakeDB {
    public function __call($name, $args) {
        printf("%s::%s(%s)\n", __CLASS__, $name, json_encode($args));
        return $this;
    }
}

class MyAuth {
    protected $dbh;
    protected $fakeUsers = [
        // old crypt-md5 format
        1 => ['password' => '$1$AVbfJOzY$oIHHCHlD76Aw1xmjfTpm5.'],
        // old salted md5 format
        2 => ['password' => '3858f62230ac3c915f300c664312c63f', 'salt' => 'bar'],
        // current bcrypt format
        3 => ['password' => '$2y$10$3eUn9Rnf04DR.aj8R3WbHuBO9EdoceH9uKf6vMiD7tz766rMNOyTO']
    ];

    public function __construct($dbh) {
        $this->dbh = $dbh;
    }

    protected function getuser($id) {
        // just pretend these are coming from the DB
        return $this->fakeUsers[$id];
    }

    public function authUser($id, $password) {
        $userInfo = $this->getUser($id);

        // Do you have old, turbo-legacy, non-crypt hashes?
        if( strpos( $userInfo['password'], '$' ) !== 0 ) {
            printf("%s::legacy_hash\n", __METHOD__);
            $res = $userInfo['password'] === md5($password . $userInfo['salt']);
        } else {
            printf("%s::password_verify\n", __METHOD__);
            $res = password_verify($password, $userInfo['password']);
        }

        // once we've passed validation we can check if the hash needs updating.
        if( $res && password_needs_rehash($userInfo['password'], PASSWORD_DEFAULT) ) {
            printf("%s::rehash\n", __METHOD__);
            $stmt = $this->dbh->prepare('UPDATE users SET pass = ? WHERE user_id = ?');
            $stmt->execute([password_hash($password, PASSWORD_DEFAULT), $id]);
        }

        return $res;
    }
}

$auth = new MyAuth(new FakeDB());

for( $i=1; $i<=3; $i++) {
    var_dump($auth->authuser($i, 'foo'));
    echo PHP_EOL;
}

Output:

MyAuth::authUser::password_verify
MyAuth::authUser::rehash
FakeDB::prepare(["UPDATE users SET pass = ? WHERE user_id = ?"])
FakeDB::execute([["$2y$10$zNjPwqQX\/RxjHiwkeUEzwOpkucNw49yN4jjiRY70viZpAx5x69kv.",1]])
bool(true)

MyAuth::authUser::legacy_hash
MyAuth::authUser::rehash
FakeDB::prepare(["UPDATE users SET pass = ? WHERE user_id = ?"])
FakeDB::execute([["$2y$10$VRTu4pgIkGUvilTDRTXYeOQSEYqe2GjsPoWvDUeYdV2x\/\/StjZYHu",2]])
bool(true)

MyAuth::authUser::password_verify
bool(true)

As a final note, given that you can only re-hash a user's password on login you should consider "sunsetting" insecure legacy hashes to protect your users. By this I mean that after a certain grace period you remove all insecure [eg: bare MD5/SHA/otherwise weak] hashes and have your users rely on your application's password reset mechanisms.

Sammitch
  • 30,782
  • 7
  • 50
  • 77
  • Yup. When I was changing our password security to use `password_hash`, I deliberately used a low `cost` value, so I could later increase it and check that `password_needs_rehash()` worked as intended. (The version with the low `cost` never went to production.) – TRiG Sep 07 '21 at 12:01