PHP / 함수 / password_verify() / 비밀번호가 저장된 해시와 일치하는지 확인하는 함수
password_verify($password, $hash)는 입력한 비밀번호가 저장된 비밀번호 해시와 일치하면 true, 일치하지 않으면 false를 반환합니다. 해시를 복호화하거나 비밀번호 원문을 반환하지 않습니다. 로그인 검증에는 새 해시를 만들어 문자열끼리 비교하는 대신 이 함수를 사용합니다.
두 인수와 반환값을 구분하기
첫 번째 인수는 지금 사용자가 입력한 원래 비밀번호 문자열이고, 두 번째 인수는 해당 계정에 저장된 해시 전체입니다. 인수 순서를 바꾸거나 두 번째 자리에 평문 비밀번호를 넣으면 원하는 검증이 되지 않습니다. 해시를 만드는 방법은 password_hash()의 생성·저장 예제를 참고하세요.
알고리즘·비용·솔트 정보는 해시에 포함되어 있어 검증 때 별도 알고리즘 인수나 솔트 인수를 전달하지 않습니다. 생성 시의 알고리즘을 처리할 수 있는 PHP 환경은 필요합니다. 함수의 반환 계약과 지원 범위는 password_verify() 공식 문서에서 확인할 수 있습니다.
전체 예제: 정확한 입력·다른 입력·잘린 해시 확인
PHP 8.x 명령행 환경에서 다음 전체 코드를 UTF-8의 password-verify-demo.php로 저장하고 실행합니다. 외부 라이브러리·DB·사용자 계정 없이 공개 실습용 문자열만 사용합니다. 실제 서비스에서는 가입 때 만든 해시를 저장해 두었다가 로그인 때 읽어야 하며, 이 예제처럼 매번 새로 초기화하면 안 됩니다.
<?php
declare(strict_types=1);
// 공개 실습용 값입니다. 실제 계정의 비밀번호가 아닙니다.
$registeredPassword = 'sample-only passphrase 42';
$storedHash = password_hash($registeredPassword, PASSWORD_DEFAULT);
$cases = [
'정확한 입력' => $registeredPassword,
'다른 입력' => 'different input',
'공백 추가' => ' ' . $registeredPassword,
];
foreach ($cases as $label => $submittedPassword) {
echo $label, ': ', password_verify($submittedPassword, $storedHash) ? '성공' : '실패', PHP_EOL;
}
$newHash = password_hash($registeredPassword, PASSWORD_DEFAULT);
echo '새 해시와 직접 비교: ', $storedHash === $newHash ? '같음' : '다름', PHP_EOL;
echo '잘린 해시: ', password_verify($registeredPassword, substr($storedHash, 0, -8)) ? '성공' : '실패', PHP_EOL;
echo '잘못된 형식: ', password_verify($registeredPassword, 'not-a-password-hash') ? '성공' : '실패', PHP_EOL;
php password-verify-demo.php
정확한 입력: 성공
다른 입력: 실패
공백 추가: 실패
새 해시와 직접 비교: 다름
잘린 해시: 실패
잘못된 형식: 실패
같은 비밀번호로 다시 만든 $newHash는 새 무작위 솔트 때문에 보통 다른 문자열입니다. 그러므로 ===나 ==로 두 해시가 같은지 확인하는 것은 비밀번호 검증이 아닙니다. 첫 번째 줄처럼 입력 원문과 기존 해시를 password_verify()에 전달해야 합니다.
공백을 추가한 입력은 원래 비밀번호와 다릅니다. 가입과 로그인에서 입력을 서로 다르게 trim()하거나 HTML 이스케이프하면 일치하지 않을 수 있습니다. 기본 원칙은 서버의 등록·변경·검증 정책을 일관되게 적용하고, 표시용 문자열과 해시 함수에 넣을 원문을 구분하는 것입니다.
저장된 해시를 읽었는데 항상 false일 때
- 해당 계정의 해시를 가져왔는지 확인합니다. 이메일·사용자 ID 조회 결과가 없거나 다른 계정의 해시를 읽은 상황부터 구분합니다.
- DB 열이 좁아 해시가 잘리지 않았는지, 저장·조회 전후의 문자열이 같은지 확인합니다. 접두어·솔트·해시의 일부만 따로 저장하거나 인코딩을 임의로 바꾸지 않습니다.
- 입력과 저장값에 예상치 않은 공백·줄바꿈이 붙었는지, PHP 문자열 리터럴을 잘못 옮겼는지 확인합니다. $가 들어간 해시를 코드에 임시로 적을 때 큰따옴표의 변수 보간에 주의합니다. 운영에서는 코드를 수정해 하드코딩하기보다 DB에서 읽습니다.
- 저장값이 password_hash() 등 지원 형식의 비밀번호 해시인지 확인합니다. MD5 문자열이나 별도 방식의 암호문을 이 함수가 자동 변환하지는 않습니다.
- 웹 요청의 값이 문자열인지 확인합니다. password[]=value처럼 배열로 들어온 값을 그대로 넘기지 말고, 요청 방식·타입·길이를 검증합니다.
해시를 생성·보관하는 일반 원칙은 PHP 비밀번호 해시 FAQ를 참고하세요. 확인 과정에서도 실제 입력 비밀번호나 해시 전체를 공개 페이지·오류 로그에 출력하지 마세요. 조회 성공 여부와 길이·형식 등 필요한 진단 정보만 접근이 제한된 환경에서 점검합니다. 양식 수신은 PHP GET·POST 입력 검증 예제처럼 누락과 배열 입력도 구분해야 합니다.
bcrypt에는 처음 72바이트까지만 처리하는 특성이 있습니다. 뒤쪽만 다른 긴 입력도 검증될 수 있으므로 등록·변경 때 입력 길이와 알고리즘 정책을 설계해야 합니다. 기존 사용자가 있는 서비스에 새로운 길이 제한을 일괄 적용하면 로그인이 막힐 수 있으니 기존 저장 방식과 이행 절차를 먼저 확인하세요. 자세한 제한은 password_hash()의 bcrypt 주의사항에 나옵니다.
검증에 성공한 뒤 필요한 경우 재해시
기존 해시가 현재 정책보다 낮은 비용이나 다른 알고리즘으로 만들어졌다면, 올바른 비밀번호를 입력받은 순간 새 해시로 바꿀 수 있습니다. password_needs_rehash()는 정책 일치 여부를 검사할 뿐 비밀번호가 맞는지 검증하지 않으므로 반드시 password_verify()가 성공한 뒤 사용합니다.
다음 전체 코드를 password-rehash-demo.php로 저장하고 php password-rehash-demo.php로 실행하세요. 두 단계가 보이도록 실습용 기존 bcrypt cost 10과 목표 cost 12를 명시했습니다. 이 숫자는 모든 서비스에 적용할 권장 설정을 뜻하지 않습니다. 이 예제는 배열 안의 값만 바꾸며 파일·DB에는 저장하지 않습니다.
<?php
declare(strict_types=1);
function verifyAndUpgrade(array &$record, string $submitted): bool {
if (!password_verify($submitted, $record['password_hash'])) {
return false;
}
$targetAlgorithm = PASSWORD_BCRYPT;
$targetOptions = ['cost' => 12];
$info = password_get_info($record['password_hash']);
if ($info['algoName'] === 'bcrypt'
&& $info['options']['cost'] < $targetOptions['cost']
&& password_needs_rehash($record['password_hash'], $targetAlgorithm, $targetOptions)) {
$record['password_hash'] = password_hash($submitted, $targetAlgorithm, $targetOptions);
}
return true;
}
// DB 대신 현재 실행 중의 배열 한 개로 흐름만 실습합니다.
$password = 'sample-only passphrase 42';
$record = ['id' => 7, 'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 10])];
$oldHash = $record['password_hash'];
echo '틀린 입력: ', verifyAndUpgrade($record, 'different input') ? '성공' : '실패', PHP_EOL;
echo '실패 후 해시 유지: ', $record['password_hash'] === $oldHash ? '예' : '아니요', PHP_EOL;
echo '정확한 입력: ', verifyAndUpgrade($record, $password) ? '성공' : '실패', PHP_EOL;
echo '새 해시 검증: ', password_verify($password, $record['password_hash']) ? '성공' : '실패', PHP_EOL;
echo '새 cost: ', password_get_info($record['password_hash'])['options']['cost'], PHP_EOL;
$upgradedHash = $record['password_hash'];
verifyAndUpgrade($record, $password);
echo '정책 충족 후 해시 유지: ', $record['password_hash'] === $upgradedHash ? '예' : '아니요', PHP_EOL;
틀린 입력: 실패
실패 후 해시 유지: 예
정확한 입력: 성공
새 해시 검증: 성공
새 cost: 12
정책 충족 후 해시 유지: 예
틀린 입력은 기존 해시를 바꾸지 않습니다. 정확한 입력으로 새 해시를 만든 뒤에는 동일한 비밀번호가 계속 검증되고, 정책을 이미 충족하면 불필요하게 다시 생성하지 않습니다. 예제는 bcrypt 비용 상향만 수행하므로 더 높은 cost의 bcrypt나 다른 알고리즘의 해시를 낮은 설정으로 바꾸지 않습니다. 다른 알고리즘으로 이행할 때는 별도 정책이 필요합니다. 검증 후 정책을 확인하는 순서는 password_needs_rehash()의 공식 사용 예를 참고하세요. 기존 해시 문자열을 입력으로 다시 해시하면 원래 비밀번호를 검증하는 저장값이 아니게 됩니다.
실제 DB에서는 조회한 계정 ID의 해시만 매개변수 바인딩으로 갱신해야 합니다. 동시 비밀번호 변경을 덮어쓰지 않도록 조회 당시 해시나 버전을 조건에 넣는 등 충돌 처리도 설계합니다. 새 해시 생성·저장 실패를 빈 문자열이나 평문 저장으로 우회하지 마세요. 함수 호출만 성공했다고 DB 갱신까지 완료된 것으로 처리해서는 안 됩니다.
인증 성공과 접근 권한은 별도로 처리
이 함수의 true는 입력과 해시가 일치한다는 뜻입니다. 계정 활성 상태·접근 권한·추가 인증 조건까지 확인했다는 뜻은 아니며, 로그인 세션도 자동으로 만들지 않습니다. 실제 로그인 흐름에서는 계정 조회, 입력 검증, 비밀번호 확인, 필요한 추가 조건과 세션 처리를 각각 올바른 순서로 수행해야 합니다.
공식 문서는 password_verify() 자체의 타이밍 공격 방어를 설명하지만, 전체 로그인 흐름에서 계정 존재 여부를 노출하는 응답·시간 차이까지 자동으로 해결해 주지는 않습니다. 사용자에게는 “아이디 또는 비밀번호가 올바르지 않습니다”처럼 실패 이유를 통합하고, HTTPS와 로그인 시도 제한 등을 함께 설계하세요. 이러한 범위는 OWASP 인증 지침을 참고할 수 있습니다.




