MD5 + salt

For a long time, MD5 plus salt felt like a practical way to handle most password storage cases. It was easy to use and usually good enough for everyday scenarios. But from a security standpoint, it still leaves risk on the table. If that is the case, it is cleaner to switch to Bcrypt directly.

MD5 + salt is often considered acceptable in many situations, especially because the chance of both the hash and the salt being compromised at the same time seems low. The bigger issue is not that combination itself, but the fact that MD5 is extremely fast. As computing power keeps improving, fast hashes are exactly the kind of thing that become easier to crack over time.

password_hash = md5(password+salt)

What Bcrypt brings

  • The hash is irreversible
  • Random salt
  • Adjustable computational cost

Code first

No need to overexplain it—here is the code first, then we can unpack what it is doing.

package main

import (
    "fmt"
    "golang.org/x/crypto/bcrypt"
)

func main() {
    password := "123456"
    fmt.Printf("第一次加密后的密码: %s\n", encryptPassword(password))
    fmt.Printf("第二次加密后的密码: %s\n", encryptPassword(password))
    fmt.Printf("密码比对结果: %v\n", comparePassword(password, encryptPassword(password)))
    fmt.Printf("密码比对结果: %v\n", comparePassword("123", encryptPassword(password)))
}

func encryptPassword(password string) string {
    hashPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        panic(err)
    }
    return string(hashPassword)
}

func comparePassword(password, hashPassword string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hashPassword), []byte(password))
    return err == nil
}
# output
第一次加密后的密码: $2a$10$dFPckrZLstSKxX8zf3uUKurLw4Pes.G3APfrDIQfVHCFyGmUq4J7K
第二次加密后的密码: $2a$10$nYbAG/Om/bjEGq..x5TsVOy5VIVWudVaFxchrWLWPO5M7tMDIBDVO
密码比对结果: true
密码比对结果: false

golang.org/x/crypto/bcrypt already exposes bcrypt, so using it is straightforward.

  • GenerateFromPassword hashes the password; the second argument is the cost factor. The higher it is, the longer the hashing takes. MaxCost is 31.
  • CompareHashAndPassword is used to verify whether the user's input matches the stored password.

What feels reassuring here is that each hash result is different. That is because the salt changes every time. With MD5, hashing the same string twice gives the same result, which is what makes simple comparison possible. Bcrypt does not work that way, so how does verification happen?

A quick look at the mechanism

Hash structure

Take a look at the hashed result:

$2a$10$nYbAG/Om/bjEGq..x5TsVOy5VIVWudVaFxchrWLWPO5M7tMDIBDVO \__/\/ \____________________/\_____________________________/ A C Salt Hash
  • A: the hashing scheme, where 2a indicates the Bcrypt version
  • C: the iteration/cost value
  • Salt: the salt
  • Hash: the final value

What is happening

Once you see the structure, the verification process is not hard to guess. In plain terms, Bcrypt verifies a password by pulling the salt back out of the stored hash, then hashing the user input again with the same algorithm and cost, and finally comparing the results.

So the stored Bcrypt string does not contain only the final hash. It also carries the hash method and the salt needed to reproduce the same result for verification.

One last thing

Compared with MD5, the biggest advantage of Bcrypt is the adjustable cost. On top of that, its baseline is already slower than MD5, which raises the cost of brute-force cracking quite a bit. And because the salt changes each time, rainbow tables are basically off the table.

There is one important caveat, though: Bcrypt has a length limit. In the Go library used here, the maximum input length is 72 bytes. Anything beyond that will trigger an error.