Menu

password_hash() và password_verify() trong PHP

Băm mật khẩu bằng password_hash($password, PASSWORD_DEFAULT), lưu kết quả dài 60 ký tự và kiểm tra khi đăng nhập bằng password_verify($password, $hash). Tìm hiểu vì sao mỗi lần băm cho kết quả khác nhau, cost, password_needs_rehash, Argon2id và vì sao không dùng md5, sha1 cho mật khẩu.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Lưu mật khẩu bằng password_hash($password, PASSWORD_DEFAULT) và kiểm tra khi đăng nhập bằng password_verify($password, $hash), hàm này trả về true hoặc false. Không bao giờ lưu chính mật khẩu, và không bao giờ dùng md5() hay sha1() cho nó.

Chạy hai lần: mỗi lần hash đều khác, và cả hai phiên bản đều kiểm tra đúng. Điều này đúng với mọi hash trên trang này, nên đầu ra của bạn sẽ không giống của ai khác.

Đăng nhập với password_verify

Khi đăng nhập, mật khẩu được gửi lên sẽ được kiểm tra với hash đã lưu lúc tạo tài khoản. Khối code này "đăng ký" một người dùng rồi hiện một form đăng nhập; thử mật khẩu đúng (hunter2), rồi thử một mật khẩu sai.

Thông báo lỗi giống nhau cho email không tồn tại và mật khẩu sai. Nói rõ cái nào sai sẽ cho kẻ tấn công biết email nào đã có tài khoản. Sau khi đăng nhập thành công, một trang web thật sẽ lưu id người dùng vào session, như trên trang session.

Bên trong hash có gì

Một hash bcrypt là một chuỗi chứa mọi thứ password_verify() cần: thuật toán, cost và salt, theo sau là chính phần hash. password_get_info() đọc lại các phần đầu đó.

Vì salt được lưu trong hash, bạn chỉ cần một cột trong cơ sở dữ liệu, và hai người dùng có cùng mật khẩu vẫn có hash khác nhau. Hãy đặt cột đó là VARCHAR(255): bcrypt dài 60 ký tự, nhưng PASSWORD_DEFAULT được phép đổi sang một thuật toán có đầu ra dài hơn.

Chọn cost

Cost là mức độ cố ý làm chậm mỗi lần băm, và mỗi bậc tăng lên làm khối lượng công việc gấp đôi. PHP 8.3 dùng cost 10 cho bcrypt (PHP 8.4 đã nâng mặc định lên 12). Một lần đăng nhập nên mất khoảng từ 100 đến 300 ms để băm; hãy đo trên máy chủ của chính bạn:

Các con số phụ thuộc vào máy, nhưng mỗi dòng nên gấp khoảng hai lần dòng trước. Cost cao hơn thì an toàn hơn trước việc bẻ khóa nhưng chậm hơn với mỗi lần đăng nhập, nên hãy chọn cost cao nhất mà trang đăng nhập của bạn chịu được.

Nâng cấp hash cũ với password_needs_rehash

Khi bạn tăng cost hoặc PHP thay đổi PASSWORD_DEFAULT, các hash hiện có vẫn hoạt động, và password_needs_rehash() cho bạn biết hash nào đã lỗi thời. Thời điểm duy nhất bạn có mật khẩu gốc là lúc đăng nhập, nên đó là chỗ để nâng cấp:

Tiền tố $2y$10$ thành $2y$12$, và ở lần đăng nhập tiếp theo password_needs_rehash() trả về false.

Không bao giờ dùng md5 hay sha1 cho mật khẩu

md5() và sha1() là các hàm băm đa dụng, chạy nhanh và không có salt. Nhanh lại chính là điều sai với mật khẩu: kẻ tấn công có trong tay một bảng bị rò rỉ có thể thử hàng tỷ lần đoán mỗi giây trên GPU, và hash không có salt của một mật khẩu phổ biến đã nằm sẵn trong các bảng tra cứu công khai. So sánh tốc độ:

Trong thời gian password_hash() tạo một hash, md5 tạo được hàng trăm nghìn, nên mỗi lần đoán của kẻ bẻ khóa rẻ hơn từng ấy lần. Tìm kiếm 5f4dcc3b5aa765d61d8327deb882cf99 và bạn sẽ thấy ngay chữ "password". Một công cụ tạo hash luôn biến "password" thành đúng chuỗi md5 đó.

Nếu bạn tiếp quản một bảng toàn hash md5, bạn không thể chuyển đổi chúng trực tiếp. Thay vào đó hãy bọc chúng lại: lưu password_hash($oldMd5) và kiểm tra bằng password_verify(md5($input), $stored), rồi thay từng hash bằng một hash bình thường vào lần đăng nhập tiếp theo của người dùng đó.

Argon2id và giới hạn 72 byte

PASSWORD_ARGON2ID dùng nhiều bộ nhớ hơn cho mỗi lần băm, khiến việc bẻ khóa bằng GPU tốn kém hơn nhiều. Nó có sẵn khi PHP được build cùng libargon2 (phần lớn package trên Linux là vậy); kiểm tra bằng defined('PASSWORD_ARGON2ID').

Dòng cuối in ra bool(true): hai mật khẩu khác nhau lại khớp vì bcrypt bỏ qua mọi thứ sau byte thứ 72. Mật khẩu thật hiếm khi dài đến vậy, nhưng các cụm mật khẩu bằng tiếng Nhật hay các hệ chữ nhiều byte khác chạm mốc 72 byte chỉ với khoảng 24 ký tự. Nếu bạn cho phép mật khẩu rất dài, hãy dùng Argon2id, thuật toán không có giới hạn này. password_verify() làm việc với cả hai thuật toán, nên bạn có thể chuyển đổi bằng password_needs_rehash() như trên.

Câu hỏi thường gặp

Vì sao password_hash cho kết quả khác nhau mỗi lần?

Mỗi lần gọi, hàm tạo một salt ngẫu nhiên mới và lưu nó bên trong chuỗi hash, nên cùng một mật khẩu lại cho ra chuỗi khác nhau mỗi lần. Đó là chủ ý: password_verify() đọc lại salt từ hash đã lưu, nên vẫn trả về true với mật khẩu đúng.

Cột mật khẩu cho password_hash nên dài bao nhiêu?

Một hash bcrypt từ PASSWORD_DEFAULT hiện dài 60 ký tự, nhưng thuật toán mặc định có thể thay đổi ở phiên bản PHP sau này. Hãy dùng VARCHAR(255) để hash dài hơn vẫn vừa mà không cần migration.

Vì sao password_verify luôn trả về false?

Nguyên nhân thường gặp là hash bị cắt ngắn (cột VARCHAR(50) cắt mất phần cuối của hash 60 ký tự), truyền một password_hash() mới tạo làm đối số thứ hai thay vì hash đã lưu, so sánh với hash tạo bằng md5(), hoặc có khoảng trắng thừa trong giá trị đã lưu. Kiểm tra strlen($hash) là 60 và truyền mật khẩu gốc làm đối số thứ nhất.

Có giải mã được giá trị password_hash trong PHP không?

Không. Hash là một chiều: không có hàm nào biến nó trở lại thành mật khẩu. Để kiểm tra mật khẩu, bạn băm mật khẩu được nhập rồi so sánh, đó chính là việc password_verify() làm. Khi người dùng quên mật khẩu, hãy gửi liên kết đặt lại thay vì gửi mật khẩu cũ.

Nên dùng PASSWORD_DEFAULT, PASSWORD_BCRYPT hay PASSWORD_ARGON2ID?

PASSWORD_DEFAULT là lựa chọn thông thường: hiện nó là bcrypt và PHP có thể chuyển nó sang thuật toán mạnh hơn sau này, khi đó password_needs_rehash() cho phép bạn chuyển dần. PASSWORD_ARGON2ID chống bẻ khóa bằng GPU tốt hơn và hoàn toàn dùng được khi bản PHP của bạn có hỗ trợ.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu