비밀번호는 password_hash($password, PASSWORD_DEFAULT)로 저장하고, 로그인은 true나 false를 반환하는 password_verify($password, $hash)로 확인하세요. 비밀번호 자체를 저장하지 말고, 그 용도로 md5()나 sha1()을 절대 쓰지 마세요.
두 번 실행해 보세요. 해시는 매번 다르고 두 버전 모두 검증됩니다. 이 페이지의 모든 해시가 마찬가지라서 여러분의 출력은 다른 누구의 것과도 일치하지 않습니다.
password_verify로 로그인하기
로그인은 제출된 비밀번호를 계정을 만들 때 저장한 해시와 비교합니다. 이 블록은 사용자 한 명을 "등록"한 다음 로그인 폼을 보여 줍니다. 올바른 비밀번호(hunter2)를 시도한 다음 틀린 것을 시도해 보세요.
알 수 없는 이메일과 틀린 비밀번호의 에러 메시지가 같습니다. 어느 쪽이 틀렸는지 말하면 공격자에게 어떤 이메일에 계정이 있는지 알려 주게 됩니다. 로그인에 성공하면 실제 사이트는 세션 페이지에서 보여 주듯 사용자 id를 세션에 저장합니다.
해시 안에 든 것
bcrypt 해시는 password_verify()에 필요한 모든 것을 담은 문자열 하나입니다. 알고리즘, cost, 솔트, 그리고 그 뒤에 해시 자체가 옵니다. password_get_info()가 앞부분을 다시 읽어 냅니다.
솔트가 해시 안에 저장되므로 데이터베이스 컬럼은 하나만 있으면 되고, 같은 비밀번호를 쓰는 두 사용자도 서로 다른 해시를 받습니다. 그 컬럼은 VARCHAR(255)로 만드세요. bcrypt는 60자지만 PASSWORD_DEFAULT는 더 긴 출력의 알고리즘으로 바뀔 수 있습니다.
cost 고르기
cost는 각 해시를 일부러 얼마나 느리게 할지이며, 한 단계 올릴 때마다 작업이 두 배가 됩니다. PHP 8.3은 bcrypt에 cost 10을 씁니다(PHP 8.4는 기본값을 12로 올렸습니다). 로그인은 해싱에 대략 100에서 300ms가 걸려야 합니다. 직접 서버에서 시간을 재 보세요.
숫자는 컴퓨터에 따라 다르지만 각 줄은 이전 줄의 약 두 배여야 합니다. 높을수록 크래킹에 안전하고 모든 로그인이 느려지므로, 로그인 페이지가 감당할 수 있는 가장 높은 cost를 고르세요.
password_needs_rehash로 예전 해시 업그레이드하기
cost를 올리거나 PHP가 PASSWORD_DEFAULT를 바꿔도 기존 해시는 계속 동작하며, password_needs_rehash()가 어떤 해시가 오래되었는지 알려 줍니다. 평문 비밀번호를 가진 유일한 순간이 로그인이므로 업그레이드는 거기서 합니다.
$2y$10$ 접두사가 $2y$12$가 되고, 다음 로그인에서 password_needs_rehash()는 false를 반환합니다.
비밀번호에 md5나 sha1을 절대 쓰지 말 것
md5()와 sha1()은 솔트가 없는 빠른 범용 해시입니다. 비밀번호에는 빠른 것이 정확히 잘못입니다. 유출된 테이블을 가진 공격자는 GPU로 초당 수십억 번 추측할 수 있고, 흔한 비밀번호의 솔트 없는 해시는 이미 공개된 조회 테이블에 있습니다. 속도를 비교해 보세요.
password_hash()가 해시 하나를 만드는 시간에 md5는 수십만 개를 만들므로, 크래커의 모든 추측이 그만큼 싸집니다. 5f4dcc3b5aa765d61d8327deb882cf99를 검색하면 바로 "password"라는 단어를 찾게 됩니다.
md5 해시 테이블을 물려받았다면 직접 변환할 수 없습니다. 대신 감싸세요. password_hash($oldMd5)를 저장하고 password_verify(md5($input), $stored)로 검증한 다음, 각 사용자가 다음에 로그인할 때 일반 해시로 바꾸세요.
Argon2id와 72바이트 제한
PASSWORD_ARGON2ID는 해시마다 더 많은 메모리를 써서 GPU 크래킹을 훨씬 비싸게 만듭니다. PHP가 libargon2와 함께 빌드되었을 때 쓸 수 있으며(대부분의 Linux 패키지가 그렇습니다), defined('PASSWORD_ARGON2ID')로 확인하세요.
마지막 줄은 bool(true)를 출력합니다. bcrypt가 72바이트 이후를 모두 무시하므로 서로 다른 두 비밀번호가 일치합니다. 실제 비밀번호가 거기에 닿는 일은 드물지만, 한글이나 일본어 같은 멀티바이트 문자로 된 문장형 비밀번호는 약 24자에서 72바이트에 닿습니다. 아주 긴 비밀번호를 허용한다면 그런 제한이 없는 Argon2id를 쓰세요. password_verify()는 두 알고리즘 모두와 동작하므로 위처럼 password_needs_rehash()로 전환할 수 있습니다.
자주 묻는 질문
password_hash가 매번 다른 결과를 주는 이유는 무엇인가요?
호출할 때마다 새 무작위 솔트를 만들어 해시 안에 저장하므로 같은 비밀번호도 매번 다른 문자열이 됩니다. 의도된 동작입니다. password_verify()가 저장된 해시에서 솔트를 다시 읽어 내므로 올바른 비밀번호에는 여전히 true를 반환합니다.
password_hash용 비밀번호 컬럼은 길이가 얼마여야 하나요?
PASSWORD_DEFAULT의 bcrypt 해시는 현재 60자지만, 기본 알고리즘은 앞으로의 PHP 버전에서 바뀔 수 있습니다. 마이그레이션 없이 더 긴 해시가 들어가도록 VARCHAR(255)를 쓰세요.
password_verify가 항상 false를 반환하는 이유는 무엇인가요?
흔한 원인은 잘린 해시(VARCHAR(50) 컬럼이 60자 해시를 자름), 저장한 해시 대신 무언가를 새로 password_hash()한 값을 두 번째 인수로 넘긴 것, md5()로 만든 해시와 비교하는 것, 저장된 값의 여분의 공백입니다. strlen($hash)가 60인지 확인하고 첫 번째 인수로 평문 비밀번호를 넘기세요.
PHP에서 password_hash 값을 복호화할 수 있나요?
아니요. 해시는 단방향입니다. 비밀번호로 되돌리는 함수는 없습니다. 비밀번호를 확인하려면 시도한 값을 해시해서 비교하며, password_verify()가 하는 일이 이것입니다. 비밀번호를 잊어버렸다면 예전 비밀번호 대신 재설정 링크를 보내세요.
PASSWORD_DEFAULT, PASSWORD_BCRYPT, PASSWORD_ARGON2ID 중 무엇을 써야 하나요?
보통은 PASSWORD_DEFAULT입니다. 현재는 bcrypt이고 PHP가 나중에 더 강한 알고리즘으로 바꿀 수 있으며, password_needs_rehash()로 점진적으로 옮겨 갈 수 있습니다. PASSWORD_ARGON2ID는 GPU 크래킹에 더 강하고, PHP 빌드에 포함되어 있다면 써도 좋습니다.