在 PHP 网站开发中,用户注册和登录看起来只是最普通的功能,但密码保存方式却直接决定了数据库泄露以后用户账户究竟有多危险。过去很多程序员习惯使用 MD5、SHA-1,甚至直接把密码存进数据库。这些做法今天已经不应该继续使用。
现代 PHP 已经提供了专门用于密码存储的 API,最核心的两个函数就是 password_hash() 和 password_verify()。正确使用它们,并不需要自己设计加密算法、自己生成盐值,更不应该先 MD5 一次、再 SHA-256 一次,然后再套几层“增强安全性”的自制方案。
密码首先要区分“加密”和“哈希”。
加密通常意味着存在一个密钥,可以把密文恢复成原文。例如数据库中的某些业务数据可能需要加密,因为系统以后还必须读取原始内容。密码则完全不同。服务器登录时真正需要知道的是“用户输入的密码是否正确”,而不是把数据库里的内容解密成用户当年设置的密码。
因此,密码通常应该使用专门设计的密码哈希算法保存。
PHP 的基本写法非常简单:
$hash = password_hash($password, PASSWORD_DEFAULT);
然后把 $hash 保存到数据库。
用户登录时,从数据库读取这个完整的哈希字符串,再执行:
if (password_verify($inputPassword, $hash)) {
// 密码正确
} else {
// 密码错误
}
这里有一个非常重要的设计思想:开发者不需要自己保存一个独立的 salt。
password_hash() 会自动生成随机盐值,并把验证所需的信息编码进最终产生的哈希字符串中。因此数据库中的密码字段通常看起来类似:
$2y$12$......
或者使用其他算法时出现不同格式。
这整串内容都非常重要。不要只截取其中一部分,也不要自己把算法名称、salt、cost 和哈希值拆开保存后再重新组合,除非你非常清楚自己正在实现什么。
使用 PASSWORD_DEFAULT 的另一个好处,是 PHP 可以随着未来版本的发展选择推荐的默认密码哈希算法。也就是说,应用程序不必把某一种具体算法永久写死。
如果明确需要 bcrypt,也可以写:
$hash = password_hash($password, PASSWORD_BCRYPT);
但对于普通新项目,PASSWORD_DEFAULT 通常是更合适的默认选择,因为它让 PHP 负责选择当前推荐的默认密码算法。
很多教程喜欢写:
password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
这种写法本身没有问题,但必须理解 cost 到底是什么意思。
bcrypt 的 cost 是一个工作因子。它不是“执行 2^12 次普通运算”这么简单,而是控制 bcrypt 计算工作的指数级参数。cost 每增加1,计算成本大致增加一倍。因此 cost=12 比 cost=11 需要明显更多计算时间。
cost 并不存在一个对所有服务器都完美的数字。服务器CPU性能、PHP版本、并发量以及登录请求规模都会影响实际选择。对于普通网站,应该在目标服务器上进行基准测试,而不是看到网上有人使用12,就认为12一定安全,或者认为14一定比12“安全一倍”。
这里还要注意一个容易忽略的问题:密码哈希故意应该比较慢。
普通 SHA-256 的设计目标是高速计算,这对于完整性校验和很多密码学用途非常有价值,但对于密码存储反而可能成为问题。攻击者一旦获得数据库,就可以使用强大的GPU或专用硬件高速尝试大量密码组合。
密码哈希算法则故意增加计算成本,让攻击者每猜一次密码都付出更高代价。
所以不要写成:
$hash = hash('sha256', $password);
然后把结果存进数据库。
更不要使用:
md5($password)
或者:
sha1($password)
作为网站密码存储方案。
即使加一个固定字符串:
sha256($password . 'mySecretSalt')
也不能把一个普通高速哈希函数变成合格的密码存储方案。
password_hash() 自动生成的 salt 还有另一个非常重要的作用:即使两个用户设置了完全相同的密码,它们生成的哈希值也通常不同。
例如两个用户都设置:
MyPassword123
系统不会因为密码相同就得到完全相同的数据库字符串。
这是因为每次 password_hash() 都会生成新的随机盐。
因此不要在数据库里寻找“相同密码对应相同哈希”的关系,也不要认为数据库中两个用户的密码哈希不同,就意味着他们使用了不同密码。
验证密码的时候,也不要自己重新生成哈希再比较:
if (password_hash($inputPassword, PASSWORD_DEFAULT) === $hash) {
这是错误思路。
因为 password_hash() 每次都会生成新的随机 salt,所以即使输入的是正确密码,新生成的哈希也通常不会与数据库里的旧哈希字符串相同。
正确方式就是:
if (password_verify($inputPassword, $hash)) {
// 登录成功
}
password_verify() 会从数据库中的哈希字符串读取算法和相关参数,然后对用户输入的密码执行相应验证。
这也是为什么开发者必须保存完整哈希字符串。
密码字段不要设置得过短。例如:
password VARCHAR(255) NOT NULL
通常比给它设置一个非常紧张的长度更加稳妥,因为 PHP 的密码 API 未来可能使用不同的算法和不同格式。不要根据今天看到的 $2y$... 字符串长度,就把数据库字段硬编码成某个特别短的尺寸。
还有一个非常实用的函数经常被忽略:
password_needs_rehash()
例如:
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$newHash = password_hash($inputPassword, PASSWORD_DEFAULT);
// 更新数据库中的密码哈希
}
它解决的是一个现实问题:密码哈希算法和参数可能随着时间发生变化。
假设你的网站十年前使用一种旧参数生成密码哈希,而今天 PHP 推荐使用新的默认算法或参数。你不需要强迫所有用户在同一天重新设置密码。
用户正常登录并验证成功以后,可以检查旧哈希是否需要升级。如果需要,就利用用户这次已经验证成功的明文密码重新生成新的哈希,然后更新数据库。
这样就可以逐渐完成密码数据库的升级。
数据库泄露以后,很多人还会犯另一个错误:认为“密码已经哈希了,所以数据库泄露也没什么关系”。
并不是这样。
密码哈希的目标,是让攻击者无法轻易从数据库字符串直接得到原始密码,但如果攻击者拿到了数据库,他仍然可以进行离线密码猜测。
如果用户使用:
123456
password
qwerty
或者生日、姓名等容易猜测的密码,那么攻击者即使面对安全的密码哈希,也可能通过大量尝试最终猜出来。
因此密码哈希只是账户安全的一层防线,而不是全部安全措施。
网站还应该限制登录尝试速度。例如同一个账户、IP、设备或者登录接口出现异常大量尝试时,应实施 rate limiting。必要时还可以加入验证码、账户临时锁定、异常登录检测以及多因素认证。
另外,不要把密码写进日志。
下面这种代码就是典型错误:
error_log("Login password: " . $password);
调试环境也不要随意记录完整密码哈希。虽然哈希不是明文密码,但它属于敏感认证数据。数据库查询日志、异常日志、调试日志、备份文件以及开发环境数据库,都应该考虑访问控制。
还有一点非常重要:不要在客户端“处理密码以后再发送”。
浏览器端使用 JavaScript 对密码进行 MD5、SHA-256 等计算,再把这个结果当作密码发送给服务器,并不能自动提高安全性。因为如果服务器把这个结果直接当成登录凭证,那么这个“哈希值”本身就变成了攻击者可以重放的密码。
正确的基本架构仍然是使用 HTTPS 保护传输,然后在服务器端进行密码验证和密码哈希。
一个最简单的注册流程可以写成:
$password = $_POST['password'];
$hash = password_hash(
$password,
PASSWORD_DEFAULT
);
// 将 $hash 写入数据库
登录流程则是:
$password = $_POST['password'];
// 从数据库读取用户对应的 password_hash
if (password_verify($password, $storedHash)) {
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
$newHash = password_hash(
$password,
PASSWORD_DEFAULT
);
// 更新数据库
}
// 登录成功
}
当然,真实网站不能只靠这几行代码。实际系统还需要使用参数化 SQL 防止 SQL 注入,正确处理 session,设置安全的 Cookie 属性,例如 Secure、HttpOnly 和合适的 SameSite,防范 CSRF,并对登录接口进行速率限制。
如果使用 PDO,数据库操作应该采用预处理语句,例如:
$stmt = $pdo->prepare(
"SELECT id, password_hash
FROM users
WHERE email = :email
LIMIT 1"
);
$stmt->execute([
':email' => $email
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
然后:
if ($user && password_verify(
$password,
$user['password_hash']
)) {
// 登录成功
}
这样密码验证过程与 SQL 查询过程彼此分开,不应该把密码直接拼接进 SQL 字符串。
最后还有一个经常被忽略的细节:不要自行限制密码只能使用某些字符,也不要为了“数据库字段长度”而随意截断密码。现代密码系统应该允许用户使用足够长的密码或密码短语。尤其不能在进入 password_hash() 之前偷偷执行 substr() 截断。
如果业务确实需要设置密码长度限制,也应该采用合理的上限,而不是人为制造一个很小的限制。
对于已经使用 MD5、SHA-1 或自定义哈希方案的旧 PHP 网站,也不要简单地把旧哈希再套一次:
password_hash(md5($password), PASSWORD_DEFAULT)
这并不能恢复旧系统失去的密码安全性,因为新的密码哈希实际上是在保护一个已经被旧算法处理过的值。
更合理的迁移方式通常是在用户下一次成功登录时,根据旧系统验证方式确认原密码,然后立即使用 password_hash() 重新生成现代密码哈希并更新数据库。对于长期不登录的账户,则根据具体业务制定强制重置密码等迁移方案。
密码安全真正需要避免的,并不是某一个 PHP 函数写错了,而是开发者试图自己发明一套密码系统。密码存储属于非常典型的“不要重新发明轮子”的领域。
今天的 PHP 已经把随机 salt、算法参数以及验证逻辑封装在 password_hash()、password_verify() 和 password_needs_rehash() 这些 API 中。开发者真正应该做的,是正确调用这些函数,再把数据库、HTTPS、Session、Cookie、速率限制和日志保护这些外围安全措施做好。
记住最核心的三件事就够了:注册时使用 password_hash(),登录时使用 password_verify(),登录成功后使用 password_needs_rehash() 检查是否需要升级哈希。
密码不应该被“保存下来供服务器解密”,而应该被转换成一个专门设计用于验证的密码哈希。服务器真正需要知道的,从来不是“用户当初设置的密码是什么”,而只是一个问题:这次输入的密码,究竟是不是正确的那个密码。
PHP 密码应该怎么保存?password_hash 和 password_verify 的正确用法
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP