Trung bình Bảo Mật

Hash mật khẩu cho đúng: bcrypt hay Argon2id, chỉnh cost và rehash khi login

Lộ bản dump là MD5, SHA-256 bị gỡ trong vài giờ. Chọn bcrypt hoặc Argon2id, đo cost trên máy thật và tự nâng cấp hash cũ khi user login trong PHP, Laravel.

26 Th10, 2026 16 phút Cập nhật 23 Th10, 2026 7 Khối code
Sơ đồ
graph TD A[User nhập mật khẩu] --> B[Lấy hash đang lưu] B --> C{Hash MD5 cũ?} C -->|có| D[So bằng hash_equals] C -->|không| E[password_verify] D -->|khớp| F[Hash lại theo chính sách mới] E -->|khớp| G{Cần rehash?} G -->|có| F G -->|không| H[Cho user đăng nhập] F --> H

Khách hàng giao cho mình một app cũ để "nâng cấp". Mở bảng users ra coi thì cột password toàn là chuỗi hex 32 ký tự: MD5 không salt. Hai tuần sau phát hiện một bucket backup bị để public. Ai cầm được bản dump đó, chỉ cần một card GPU bình thường với một wordlist trên mạng, là gỡ ra gần hết mật khẩu trong một buổi chiều. Mà người dùng thì hay xài lại mật khẩu, nên thiệt hại đâu chỉ dừng ở app của mình, tiếp theo là email, là tài khoản ngân hàng của họ.

Lưu mật khẩu là một trong số ít chỗ mà "nhanh" chính là bug. Cách sửa không phức tạp: một hàm hash mật khẩu chậm, có salt, tốn bộ nhớ; một mức cost mình đo thiệt chứ không đoán; và một luồng login tự động nâng cấp hash cũ mà user hổng hay biết. Bài này đi qua từng phần trong PHP 8.4 và Laravel 13, kèm mấy chỗ cần để ý khi migrate một tập user đang chạy thiệt.

Vì sao MD5, SHA-1, SHA-256 là sai công cụ

Mấy hàm hash đa dụng được thiết kế để chạy nhanh. Nhanh là đúng ý khi làm checksum hay HMAC, nhưng cũng đúng ý kẻ tấn công khi họ đoán mật khẩu offline. Một khi hash đã bị lộ, họ đâu còn đụng tới rate limiter hay cơ chế khóa tài khoản của mình nữa. Họ chạy đoán trên máy của họ, nhanh cỡ nào thuật toán cho phép thì nhanh cỡ đó: hàng chục tỷ lượt đoán MD5 mỗi giây trên một GPU, so với vài ngàn lượt cho bcrypt.

Thêm salt (sha256($salt . $password)) thì chặn được rainbow table tính sẵn và bắt họ phải làm riêng cho từng user, nhưng tốc độ thô vẫn y nguyên. Chạy SHA-256 vài ngàn lần trong vòng lặp thì đỡ hơn, PBKDF2 về cơ bản là làm vậy, nhưng GPU vẫn song song hóa ngon lành vì nó gần như hổng tốn bộ nhớ.

Một hàm hash mật khẩu đúng nghĩa cần ba thứ: salt ngẫu nhiên riêng cho từng mật khẩu, work factor chỉnh được để theo kịp phần cứng, và tốt nhất là "memory-hard" để GPU hay ASIC không chạy song song hàng ngàn lượt đoán một cách rẻ tiền được. bcrypt có hai cái đầu. Argon2id có đủ cả ba.

bcrypt hay Argon2id

bcrypt (1999) là lựa chọn mặc định, nhàm chán nhưng đã được kiểm chứng. Nó là PASSWORD_DEFAULT trong PHP và là driver mặc định của Laravel. Work factor của nó là số mũ cost: cost 12 nghĩa là 2^12 vòng key setup tốn kém, mỗi lần +1 là thời gian tăng gấp đôi. PHP 8.4 đã nâng cost bcrypt mặc định từ 10 lên 12, và BCRYPT_ROUNDS mặc định của Laravel cũng là 12. bcrypt chỉ xài khoảng 4 KiB bộ nhớ, đủ nhỏ để GPU vẫn tấn công được, chỉ là chậm.

bcrypt có một điểm cần nhớ: nó chỉ lấy 72 byte đầu của input. Hai passphrase giống nhau ở 72 byte đầu sẽ ra cùng một hash. Với mật khẩu bình thường thì hổng sao. Nhưng nếu mình ghép thêm username hay một pepper dài phía trước mật khẩu, hoặc passphrase UTF-8 dài (72 byte có khi chưa tới 30 ký tự tiếng Việt có dấu), thì phải để ý.

Argon2id thắng cuộc thi Password Hashing Competition năm 2015 và hiện là lựa chọn số một của OWASP. Mình chỉnh ba thông số: bộ nhớ (memory_cost, đơn vị KiB), số vòng (time_cost) và số luồng (threads). Vì mỗi lượt đoán cần một khối bộ nhớ riêng, kẻ tấn công xài GPU sẽ cạn băng thông bộ nhớ trước khi cạn core. Nó cũng không bị giới hạn 72 byte.

Quy tắc thực tế: project mới mà mình làm chủ server thì Argon2id với tối thiểu 19 MiB bộ nhớ (mức tối thiểu OWASP) là lựa chọn tốt hơn, và 64 MiB là mặc định hợp lý nếu lượng login chịu nổi. bcrypt cost 12 trở lên vẫn hoàn toàn ổn, và tốt hơn gấp nhiều lần mấy hàm hash nhanh. Đừng xài PBKDF2 trần trừ khi bị ép bởi chuẩn compliance như FIPS, và khi đó thì đặt số vòng cao (OWASP gợi ý 600.000 cho PBKDF2-HMAC-SHA256).

Luồng verify rồi rehash

Ý tưởng giúp nâng cấp hash nhẹ nhàng: mình chỉ hash lại được khi có plaintext, mà lúc duy nhất có plaintext là khi user login thành công. Vậy nên mỗi lần login thành công là một cơ hội để kiểm tra hash đang lưu còn khớp chính sách hiện tại không, không khớp thì thay luôn.

graph TD A[User nhập mật khẩu] --> B[Lấy hash đang lưu] B --> C{Hash MD5 cũ?} C -->|có| D[So bằng hash_equals] C -->|không| E[password_verify] D -->|khớp| F[Hash lại theo chính sách mới] E -->|khớp| G{Cần rehash?} G -->|có| F G -->|không| H[Cho user đăng nhập] F --> H

Nhánh legacy xử lý mấy hash có từ trước khi migrate. Nhánh bình thường xử lý cả chuyện đổi cost lẫn đổi thuật toán (bcrypt sang Argon2id) bằng đúng một đoạn code, vì bản thân chuỗi hash đã ghi lại thuật toán và tham số của nó.

Làm bằng PHP 8.4 thuần

API password của PHP lo hết phần nặng. Đừng gọi crypt() trực tiếp, đừng tự sinh salt, và đừng bao giờ so sánh hash bằng ===.

// Registration: let PHP pick the algorithm, salt and encoding.
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);

// Store $hash in a VARCHAR(255) column. It looks like:
// $2y$12$<22 chars of salt><31 chars of hash>

// Login: never compare hashes yourself.
if (password_verify($plainPassword, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        $hash = password_hash($plainPassword, PASSWORD_DEFAULT);
        // persist the new $hash for this user
    }
    // log the user in
}

password_needs_rehash() trả về true nếu hash đang lưu xài thuật toán hoặc tham số khác với cái mình truyền vô. Tăng cost, deploy, và user nào login lần tới cũng được nâng cấp.

Cost bao nhiêu là đủ? Trả lời thật lòng là "cao nhất mà độ trễ login và ngân sách CPU cho phép". Mốc hợp lý là khoảng 250 tới 500 ms mỗi lần hash trên phần cứng production. Đo chứ đừng đoán:

// Run this on production-like hardware, not on your laptop.
$timeTarget = 0.25; // seconds per hash you are willing to spend
$cost = 9;

do {
    $cost++;
    $start = microtime(true);
    password_hash('benchmark-password', PASSWORD_BCRYPT, ['cost' => $cost]);
    $elapsed = microtime(true) - $start;
} while ($elapsed < $timeTarget && $cost < 16);

printf("Use cost %d (%.3f s per hash)%s", $cost, $elapsed, PHP_EOL);

Nhớ là thời gian này bị tốn cho mọi lần thử login, kể cả khi kẻ xấu spam form login. Đi kèm cost cao phải có rate limiting (RateLimiter hoặc middleware throttle của Laravel), để việc hash mật khẩu hổng biến thành cách đốt CPU của mình một cách rẻ tiền.

Argon2id xài cùng API:

if (! defined('PASSWORD_ARGON2ID')) {
    throw new RuntimeException('PHP was built without Argon2 support.');
}

$options = [
    'memory_cost' => 65536, // KiB, so 64 MiB per hash
    'time_cost' => 4,       // passes over that memory
    'threads' => 1,         // libsodium builds only support 1
];

$hash = password_hash($plainPassword, PASSWORD_ARGON2ID, $options);
// $argon2id$v=19$m=65536,t=4,p=1$<salt>$<hash>

$valid = password_verify($plainPassword, $hash);
$stale = password_needs_rehash($hash, PASSWORD_ARGON2ID, $options);

PASSWORD_ARGON2ID chỉ có khi PHP được build kèm libargon2 hoặc libsodium. Docker image chính thức và đa số package của distro đều có, nhưng nên kiểm tra trong CI chứ đừng để lên production mới biết. Memory cost là tính trên mỗi lần hash, nên 64 MiB nhân 50 lượt login đồng thời là 3,2 GiB. Tính toán số worker PHP-FPM cho phù hợp.

Laravel 13: Hash facade, config và rehash khi login

Laravel bọc mấy hàm đó sau facade Hash và đọc chính sách từ config/hashing.php. Nếu app chưa có file này thì publish bằng php artisan config:publish hashing.

// config/hashing.php (trimmed)
return [
    'driver' => env('HASH_DRIVER', 'bcrypt'), // or 'argon2id'

    'bcrypt' => [
        'rounds' => env('BCRYPT_ROUNDS', 12),
        'verify' => env('HASH_VERIFY', true),
    ],

    'argon' => [
        'memory' => env('ARGON_MEMORY', 65536),
        'threads' => env('ARGON_THREADS', 1),
        'time' => env('ARGON_TIME', 4),
        'verify' => env('HASH_VERIFY', true),
    ],

    'rehash_on_login' => true,
];

Đổi HASH_DRIVER sang argon2id không làm hỏng user cũ: Laravel vẫn verify được hash bcrypt cũ, và rehash_on_login sẽ nâng cấp dần khi mọi người login.

Model User mặc định cast password sang hashed, nên gán plaintext vô là tự hash, còn gán một hash có sẵn thì nó để nguyên. Từ Laravel 11, Auth::attempt() gọi rehashPasswordIfRequired() của user provider sau khi check thành công, nên nâng cost hay đổi driver mình không phải viết thêm dòng nào. Nếu có viết UserProvider riêng thì phải implement method đó, không thì việc nâng cấp âm thầm không bao giờ xảy ra.

Cái Laravel không làm giùm được là migrate mấy hash nó không hiểu, như cột MD5 trần ở đầu bài. Làm hai bước. Bước một, chạy một command một lần để bọc mọi giá trị MD5 trong bcrypt, để không còn hash nhanh nào nằm trong DB, kể cả với user không bao giờ login lại:

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;

final class WrapLegacyPasswords extends Command
{
    protected $signature = 'passwords:wrap-legacy';

    protected $description = 'Replace raw MD5 hashes with bcrypt(md5) so no fast hash stays at rest';

    public function handle(): int
    {
        User::query()
            ->whereNotNull('legacy_md5')
            ->chunkById(500, function (Collection $users): void {
                foreach ($users as $user) {
                    $user->forceFill([
                        'password' => Hash::make($user->legacy_md5),
                        'password_is_wrapped' => true,
                        'legacy_md5' => null,
                    ])->save();
                }
            });

        $this->info('Legacy hashes wrapped.');

        return self::SUCCESS;
    }
}

Bước hai, lúc login, user có hash đã bọc được kiểm tra với bcrypt(md5(password)) rồi gỡ ra thành hash bình thường:

namespace App\Http\Controllers\Auth;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

final class LoginController
{
    public function __invoke(Request $request): RedirectResponse
    {
        $credentials = $request->validate([
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        $this->unwrapLegacyHash($credentials['email'], $credentials['password']);

        // Auth::attempt() verifies the hash and, with rehash_on_login enabled,
        // re-hashes it when the cost or algorithm in config/hashing.php changed.
        if (! Auth::attempt($credentials, $request->boolean('remember'))) {
            throw ValidationException::withMessages([
                'email' => __('auth.failed'),
            ]);
        }

        $request->session()->regenerate();

        return redirect()->intended('/dashboard');
    }

    private function unwrapLegacyHash(string $email, string $password): void
    {
        $user = User::query()->where('email', $email)->first();

        if ($user === null || ! $user->password_is_wrapped) {
            return;
        }

        // Stored value is bcrypt(md5(password)) from the one-time migration.
        if (Hash::check(md5($password), $user->password)) {
            $user->forceFill([
                'password' => $password, // the 'hashed' cast hashes it
                'password_is_wrapped' => false,
            ])->save();
        }
    }
}

Kiểm thử luồng nâng cấp (test)

Code hash mật khẩu thì dễ "chạy được" nhưng khó biết là đúng. Test mấy hành vi quan trọng: user vẫn login được sau khi migrate, hash lưu trong DB thật sự đổi, và login sai thì không đổi gì hết. Giữ test chạy nhanh bằng cost thấp: phpunit.xml của skeleton Laravel đã set sẵn BCRYPT_ROUNDS=4.

use App\Models\User;
use Illuminate\Support\Facades\Hash;

it('unwraps a legacy md5 password on the first successful login', function () {
    $user = User::factory()->create([
        'password' => Hash::make(md5('old-secret-123')),
        'password_is_wrapped' => true,
    ]);

    $this->post('/login', ['email' => $user->email, 'password' => 'old-secret-123'])
        ->assertRedirect('/dashboard');

    $user->refresh();

    expect($user->password_is_wrapped)->toBeFalse()
        ->and(Hash::check('old-secret-123', $user->password))->toBeTrue()
        ->and(Hash::check(md5('old-secret-123'), $user->password))->toBeFalse();

    $this->assertAuthenticatedAs($user);
});

it('rehashes on login after the bcrypt cost goes up', function () {
    $user = User::factory()->create([
        'password' => password_hash('secret-pass-1', PASSWORD_BCRYPT, ['cost' => 4]),
    ]);

    config(['hashing.bcrypt.rounds' => 5]);
    Hash::forgetDrivers();

    $this->post('/login', ['email' => $user->email, 'password' => 'secret-pass-1'])
        ->assertRedirect('/dashboard');

    expect(Hash::info($user->refresh()->password)['options']['cost'])->toBe(5);
});

Hash::forgetDrivers() xóa hasher đã cache để giá trị rounds mới có hiệu lực ngay trong cùng test. Test thứ hai là cái bảo vệ mình khỏi chuyện một provider tự viết lặng lẽ làm hỏng rehash-on-login.

Mấy lỗi hay gặp

  • So sánh hash bằng == hay ===. Xài password_verify() hoặc Hash::check(). Với MD5 legacy thì xài hash_equals() để so sánh constant-time, và tuyệt đối đừng xài ==, vì nó coi chuỗi kiểu "0e123..." là số.
  • Cột quá ngắn. bcrypt dài 60 ký tự, Argon2id khoảng 97 với tham số mặc định và dài hơn khi tăng tham số. Cứ VARCHAR(255) để thuật toán sau này không bị cắt cụt.
  • Hash ở phía client. Nếu browser gửi lên sha256(password) thì với server, cái hash đó chính là mật khẩu. Xài HTTPS và hash ở server.
  • Pepper làm hỏng bcrypt. Ghép một chuỗi bí mật dài phía trước đẩy mật khẩu thật ra ngoài 72 byte. Muốn có pepper thì áp dụng kiểu hash_hmac('sha256', $password, $pepper), base64 kết quả rồi mới đưa vô bcrypt, và tính trước chuyện xoay vòng pepper.
  • Quên hạ cost trong test. Không có BCRYPT_ROUNDS=4, một test suite tạo hàng ngàn user sẽ mất mấy phút chỉ để hash.
  • Để MD5 nằm lại trong DB. Kiểu migrate lười "nâng cấp khi login" sẽ giữ hash nhanh mãi mãi cho user không hoạt động. Bọc hết ngay lập tức như ở trên.

Khi nào xài cái nào (và khi nào không)

  • App Laravel mới: giữ bcrypt mặc định cost 12, hoặc chuyển sang Argon2id nếu PHP hỗ trợ và đủ RAM. Cách nào thì cũng để rehash_on_login bật.
  • Login traffic cao hoặc RAM eo hẹp: bcrypt dễ đoán trước và nhẹ RAM. Tăng cost dần theo thời gian thay vì đổi thuật toán.
  • Nhận lại hash MD5/SHA-1: bọc bằng bcrypt ngay, gỡ ra khi login, và bắt reset với phần còn lại sau vài tháng.
  • API key và token ngẫu nhiên dài: đừng xài hash mật khẩu. Chúng vốn đã có entropy cao, một lần SHA-256 là đủ, Sanctum lưu personal access token đúng kiểu đó. Hash chậm là dành cho bí mật entropy thấp do con người tự đặt.

Tóm lại: không lưu thứ gì nhanh, để nền tảng lo salt và encoding, đo cost trên phần cứng thật, và coi mỗi lần login thành công là một bước migrate miễn phí.

Thử thách tương tác

Thử thách

Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.

Đề bài

PHP: viết class PasswordUpgrader. hash(string $password): string trả về hash bcrypt (PASSWORD_BCRYPT) với cost truyền vô constructor. verify(string $password, string $storedHash): array trả về ['ok' => bool, 'rehash' => ?string]. Nếu $storedHash đúng 32 ký tự hex chữ thường thì đó là MD5 cũ không salt: so với md5($password) bằng hash_equals(); khớp thì trả ok true kèm một hash bcrypt mới trong 'rehash'. Còn lại thì xài password_verify(); sai thì trả ['ok' => false, 'rehash' => null]; đúng thì trả ok true, và chỉ trả hash mới khi password_needs_rehash() báo hash đang lưu không khớp cost cấu hình, không thì null. Python: viết hash_password(password, iterations, salt=None) trả về 'pbkdf2_sha256$<iterations>$<salt hex>$<hash hex>' (PBKDF2-HMAC-SHA256, salt ngẫu nhiên 16 byte khi salt là None) và verify_and_upgrade(password, stored, iterations) trả về (ok, hash_mới_hoặc_None) với cùng quy tắc MD5 cũ, so sánh constant-time bằng hmac.compare_digest, trả (False, None) với định dạng lạ, và chỉ trả hash mới khi số vòng đang lưu nhỏ hơn iterations.

Code khởi tạo

php
final class PasswordUpgrader
{
    public function __construct(private int $cost = 12) {}

    public function hash(string $password): string
    {
        // TODO: bcrypt the password with $this->cost
        throw new LogicException('TODO');
    }

    /** @return array{ok: bool, rehash: ?string} */
    public function verify(string $password, string $storedHash): array
    {
        // TODO: legacy branch - a 32-char lowercase hex hash is unsalted MD5:
        //       compare with hash_equals() and rehash on success
        // TODO: otherwise password_verify(); on success, rehash only when
        //       password_needs_rehash() says the stored hash is stale
        throw new LogicException('TODO');
    }
}
python
import hashlib
import hmac
import os
import re


def hash_password(password: str, iterations: int, salt: bytes | None = None) -> str:
    # TODO: random 16-byte salt when none is given, PBKDF2-HMAC-SHA256,
    #       return "pbkdf2_sha256$<iterations>$<salt hex>$<hash hex>"
    raise NotImplementedError


def verify_and_upgrade(password: str, stored: str, iterations: int) -> tuple[bool, str | None]:
    # TODO: 32-char lowercase hex -> legacy MD5, compare with hmac.compare_digest
    # TODO: parse the pbkdf2_sha256 format, recompute, compare in constant time
    # TODO: on success return a new hash only if stored iterations < iterations
    raise NotImplementedError

Lời giải của bạn

Gợi ý

Đã mở hết gợi ý
Gợi ý 1

Kiểm tra định dạng MD5 cũ trước bằng regex kiểu ^[a-f0-9]{32}$, vì password_verify() gặp nó chỉ trả về false

Gợi ý 2

Đừng so sánh bí mật bằng == : xài hash_equals() trong PHP và hmac.compare_digest() trong Python

Gợi ý 3

Chỉ tạo hash mới sau khi verify thành công; login sai thì không được trả rehash

Lời giải · php
final class PasswordUpgrader
{
    public function __construct(private int $cost = 12) {}

    public function hash(string $password): string
    {
        return password_hash($password, PASSWORD_BCRYPT, ['cost' => $this->cost]);
    }

    /** @return array{ok: bool, rehash: ?string} */
    public function verify(string $password, string $storedHash): array
    {
        if (preg_match('/^[a-f0-9]{32}$/', $storedHash) === 1) {
            $ok = hash_equals($storedHash, md5($password));

            return ['ok' => $ok, 'rehash' => $ok ? $this->hash($password) : null];
        }

        if (! password_verify($password, $storedHash)) {
            return ['ok' => false, 'rehash' => null];
        }

        $stale = password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => $this->cost]);

        return ['ok' => true, 'rehash' => $stale ? $this->hash($password) : null];
    }
}
Lời giải · python
import hashlib
import hmac
import os
import re


def hash_password(password: str, iterations: int, salt: bytes | None = None) -> str:
    salt = os.urandom(16) if salt is None else salt
    dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
    return f"pbkdf2_sha256${iterations}${salt.hex()}${dk.hex()}"


def verify_and_upgrade(password: str, stored: str, iterations: int) -> tuple[bool, str | None]:
    if re.fullmatch(r"[0-9a-f]{32}", stored):
        ok = hmac.compare_digest(stored, hashlib.md5(password.encode()).hexdigest())
        return ok, (hash_password(password, iterations) if ok else None)

    parts = stored.split("$")
    if len(parts) != 4 or parts[0] != "pbkdf2_sha256":
        return False, None
    _, iters, salt_hex, hash_hex = parts
    dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), int(iters))
    if not hmac.compare_digest(dk.hex(), hash_hex):
        return False, None
    return True, (hash_password(password, iterations) if int(iters) < iterations else None)

Cùng chủ đề Bảo Mật