PHP / 함수 / password_hash() / 비밀번호 해시를 생성하는 함수
password_hash()는 비밀번호를 복호화 가능한 암호문이 아닌 단방향 해시로 변환하는 PHP 함수입니다. 회원 정보에는 생성한 해시 전체를 저장하고, 로그인 때는 password_verify()로 입력 비밀번호와 저장된 해시의 일치 여부를 확인합니다. 솔트가 자동으로 생성되므로 같은 비밀번호에서도 보통 서로 다른 해시가 나옵니다.
비밀번호에 전용 해시 함수를 사용하는 이유
해시는 원문을 다시 꺼내기 위해 저장하는 값이 아닙니다. 비밀번호를 잊었다면 저장된 해시를 복호화하는 대신 본인 확인 후 새 비밀번호로 재설정하는 흐름을 사용합니다. 평문 저장과 빠른 일반 해시 함수의 문제는 PHP 비밀번호 해시 FAQ에 설명되어 있습니다. md5()나 단순 SHA-256 처리로 비밀번호 저장 방식을 대신하지 마세요.
솔트(salt)는 해시를 만들 때 사용하는 무작위 값입니다. 같은 입력의 해시가 일률적으로 같아지지 않게 하여 미리 계산한 값이나 여러 계정에 대한 추측을 재사용하기 어렵게 합니다. password_hash()가 솔트를 생성하고 결과 문자열에 필요한 정보를 포함하므로 직접 솔트를 만들어 별도 열에 저장할 필요가 없습니다. 솔트가 있다고 약한 비밀번호의 추측 가능성까지 사라지는 것은 아닙니다.
기본 예제: 생성한 두 해시를 각각 검증하기
PHP 8.x의 명령행 실행 환경을 준비하고 다음 전체 코드를 UTF-8의 password-hash-demo.php로 저장합니다. 별도 라이브러리나 DB 없이 실행할 수 있습니다. 예제는 함수 동작을 확인하는 로컬 실습이며 실제 비밀번호를 화면이나 로그에 출력하는 용도로 사용하면 안 됩니다.
<?php
declare(strict_types=1);
function createPasswordHash(string $password): string {
if ($password === '' || str_contains($password, "\0")) {
throw new InvalidArgumentException('빈 비밀번호와 NUL 문자는 허용하지 않습니다.');
}
$algorithm = PASSWORD_DEFAULT;
if ($algorithm === PASSWORD_BCRYPT && strlen($password) > 72) {
throw new InvalidArgumentException('bcrypt 비밀번호는 72바이트 이하여야 합니다.');
}
return password_hash($password, $algorithm);
}
// 공개 실습 전용 문자열입니다. 실제 계정의 비밀번호를 넣지 마세요.
$password = 'sample-only passphrase 42';
$hash1 = createPasswordHash($password);
$hash2 = createPasswordHash($password);
echo '해시 1: ', $hash1, PHP_EOL;
echo '해시 2: ', $hash2, PHP_EOL;
echo '두 해시 동일: ', $hash1 === $hash2 ? '예' : '아니요', PHP_EOL;
echo '검증 1: ', password_verify($password, $hash1) ? '성공' : '실패', PHP_EOL;
echo '검증 2: ', password_verify($password, $hash2) ? '성공' : '실패', PHP_EOL;
echo '다른 입력: ', password_verify('different input', $hash1) ? '성공' : '실패', PHP_EOL;
echo '알고리즘: ', password_get_info($hash1)['algoName'], PHP_EOL;
echo '길이: ', strlen($hash1), PHP_EOL;
php password-hash-demo.php
두 해시 문자열은 실행마다 보통 달라집니다. “두 해시 동일: 아니요”, “검증 1: 성공”, “검증 2: 성공”, “다른 입력: 실패”가 핵심 확인 결과입니다. 해시 문자열이 다르다고 비밀번호가 다르다고 판단하거나, 새로 만든 해시와 기존 해시를 ===로 비교해 로그인 여부를 결정하지 마세요.
예제의 createPasswordHash()는 빈 문자열·NUL 문자와 bcrypt의 과도한 입력 길이를 거절하는 실습용 래퍼입니다. 서비스의 비밀번호 길이·유출 비밀번호 차단 등의 전체 정책을 구현한 함수는 아닙니다. 비밀번호에 trim()이나 HTML 이스케이프를 적용해 원래 입력을 바꾸지 않고 해시를 생성합니다. 입력 전송·인증·저장 정책은 별도로 설계해야 합니다.
출력하는 알고리즘과 길이는 현재 실행 환경의 결과입니다. password_get_info()는 해시에 포함된 알고리즘·옵션을 확인하는 데 사용하며, 사용자 비밀번호가 맞는지 판정하는 함수는 아닙니다. 각각의 동작은 password_get_info()와 password_verify() 문서를 참고하세요.
알고리즘과 옵션을 선택할 때 확인할 사항
| 지정 값 | 용도와 조건 |
|---|---|
| PASSWORD_DEFAULT | PHP의 기본 알고리즘을 사용합니다. 현재 bcrypt이지만 향후 변경될 수 있으므로 결과 형식·길이를 고정해서 가정하지 않습니다. |
| PASSWORD_BCRYPT | bcrypt를 명시합니다. cost와 72바이트 입력 제한을 고려합니다. |
| PASSWORD_ARGON2ID | PHP 빌드의 Argon2 지원이 필요합니다. 신규 설계에서는 지원 환경과 메모리·시간 비용을 확인해 검토합니다. |
| PASSWORD_ARGON2I | 지원 빌드에서 사용할 수 있는 Argon2i입니다. Argon2id와 같은 알고리즘 이름으로 취급하지 않습니다. |
OWASP 비밀번호 저장 지침은 신규 설계에서 Argon2id를 우선하며, bcrypt는 제약이 있는 기존 시스템 맥락에서 다룹니다. PASSWORD_DEFAULT는 PHP API의 기본 선택이라는 뜻이며 모든 서비스의 보안 정책을 자동 결정해 주지는 않습니다. 아래 argon2-demo.php는 현재 빌드의 지원 여부와 기본 옵션의 생성·검증 동작만 확인합니다.
<?php
declare(strict_types=1);
echo '지원 목록: ', implode(', ', password_algos()), PHP_EOL;
if (!defined('PASSWORD_ARGON2ID') || !in_array('argon2id', password_algos(), true)) {
echo '이 PHP 환경은 Argon2id를 제공하지 않습니다.', PHP_EOL;
exit;
}
$password = 'sample-only passphrase 42';
$hash = password_hash($password, PASSWORD_ARGON2ID);
echo '알고리즘: ', password_get_info($hash)['algoName'], PHP_EOL;
echo '검증: ', password_verify($password, $hash) ? '성공' : '실패', PHP_EOL;
php argon2-demo.php로 실행했을 때 지원 환경이면 알고리즘 argon2id와 “검증: 성공”이 출력됩니다. 미지원이면 그 사실을 표시하고 종료합니다. 실제 설치 상태는 password_algos()로 확인할 수 있습니다. 라이브러리를 설치하지 않은 환경에서 상수 이름만 적는 것으로 지원이 추가되지는 않습니다.
bcrypt의 cost는 연산 비용, Argon2의 memory_cost·time_cost·threads는 메모리·반복·병렬 처리 관련 옵션입니다. threads 지원 등은 구현에 따라 다를 수 있으므로 지원 옵션과 단위를 확인해야 합니다. 운영 서버의 예상 동시 로그인 부하에서 생성·검증 지연과 메모리 사용을 측정하고, 처리 속도를 높인다는 이유로 비용을 무조건 낮추지 마세요.
PHP 8.4에서는 bcrypt의 기본 cost가 10에서 12로 바뀌었습니다. 따라서 서버 업그레이드만으로도 기본 옵션의 처리 비용이 달라질 수 있습니다. 버전 차이는 PHP 8.4 변경 사항, 옵션과 실패 시 예외는 password_hash() 문서를 확인하세요. PHP 8에서는 실패를 false 반환 검사만으로 처리하지 말고 예외 처리 경로를 마련해야 합니다.
bcrypt의 72바이트 제한과 입력 보존
bcrypt는 비밀번호의 처음 72바이트까지만 처리합니다. 이를 방치하면 그 뒤의 내용만 다른 입력이 같은 비밀번호로 검증될 수 있습니다. 예제는 PASSWORD_DEFAULT가 bcrypt인 환경에서 72바이트 초과를 해시 생성 전에 거절합니다. 실제 서비스에서도 등록·변경 단계의 길이 정책과 기존 계정 검증 정책을 일관되게 설계해야 합니다.
72는 글자 수가 아닌 바이트 수입니다. UTF-8의 “가”는 3바이트이므로 24번은 72바이트, 25번은 75바이트입니다. strlen()으로 이 차이를 확인할 수 있습니다. 입력을 72바이트에서 임의로 자르거나, 제한 회피용으로 임의의 빠른 해시를 앞에 붙이는 자체 방식을 만들지 말고 더 긴 입력을 지원하는 알고리즘과 검증된 저장 정책을 검토하세요.
비밀번호의 앞뒤 공백도 실제 값의 일부일 수 있습니다. 가입 때만 공백을 없애고 로그인 때는 보존하는 식으로 처리하면 같은 입력을 재현할 수 없습니다. 해시 함수에 전달할 원문과 표시용 문자열을 구분하고, HTML 이스케이프나 문자 인코딩 변환으로 원문을 바꾸지 않도록 합니다.
해시 저장과 재해시를 수행하는 시점
DB에는 password_hash()가 반환한 문자열 전체를 잘리지 않게 저장합니다. bcrypt의 현재 길이만 보고 60자로 열을 고정하지 말고, PHP 문서가 제안하는 255바이트 이상의 여유를 가진 열을 사용하세요. VARCHAR(255) 같은 설계에서도 실제 저장·조회 후 문자열이 원본 해시와 동일한지 확인해야 합니다. 매개변수 바인딩으로 저장하고 평문 비밀번호와 해시를 디버그 로그에 남기지 않습니다.
알고리즘·비용·솔트 정보가 결과에 들어 있으므로 이를 문자열에서 임의로 분리하거나 덧붙이지 마세요. 자동 솔트를 사용하는 것이 기본이며, PHP 8에서는 직접 지정하는 salt 옵션을 지원 방식으로 삼을 수 없습니다. 해시 생성에 실패하면 평문이나 빈 해시를 대신 저장하지 말고 등록·변경 처리를 중단해야 합니다.
기존 해시가 새로운 정책에 맞는지는 password_needs_rehash()로 확인할 수 있습니다. 일반적인 갱신 시점은 사용자가 입력한 비밀번호가 기존 해시와 일치한 직후입니다. 그때 입력 원문으로 새 해시를 만들고 해당 계정의 기존 값을 교체합니다. 기존 해시 문자열 자체를 다시 해시해 원래 비밀번호를 업데이트하려고 하면 검증 방식이 달라집니다.
비밀번호 해시를 올바르게 저장하는 것은 인증 시스템의 한 부분입니다. 실제 로그인에는 HTTPS, 로그인 시도 제한, 계정별 권한 확인과 세션 관리 등이 함께 필요합니다. 해시가 노출되었을 때의 대응과 비밀번호 재설정 절차도 별도로 준비해야 합니다.




