비밀번호 해시 입문: bcrypt 와 Argon2id 를 실측으로 고르기
보안 기초 강좌 2. 솔트 없는 SHA-256 을 직접 만든 20단어 목록으로 깨 보고, bcrypt·Argon2id·scrypt·PBKDF2 의 1회 비용을 실측합니다. bcrypt 72바이트 제한도 PHP 8.5 에서 재현.
시리즈: 보안 기초 강좌 2/6 (초급). 처음으로 회원가입 폼을 만드는 사람, 또는 이미 있는 사용자 테이블을 넘겨받았는데 비밀번호가 안전하게 저장돼 있는지 확신이 없는 사람을 위한 글입니다. 다 읽고 나면 PHP 와 Python 으로 비밀번호를 올바르게 저장·대조할 수 있고, '충분히 느리다'가 몇 밀리초인지 말할 수 있으며, 유출 사고 보고서에 단골로 나오는 실수를 피할 수 있습니다.
강좌 1의 마지막 숫자를 떠올려 보세요. 노트북 코어 하나가 SHA-256 을 초당 약 789만 번 계산합니다. 이번 강좌는 그 숫자가 'DB 가 유출된 날' 무엇을 뜻하는지 봅니다. 한국에서는 개인정보보호법과 관련 기준에 따라 비밀번호를 일방향 암호화해서 저장하라는 요구를 일찍부터 듣게 되는데, 이 '일방향 암호화'가 바로 이 글에서 다루는 비밀번호 해시입니다. 문제는 "SHA-256 도 일방향이니 됐다"에서 멈추는 경우가 많다는 점입니다. 정보보안기사 시험에서도 솔트, 키 스트레칭, 레인보 테이블은 단골 주제인데, 이 글은 그 개념을 실제 숫자로 확인합니다. (법령의 구체적인 요건은 전문가에게 확인하세요. 여기서는 일반론만 다룹니다.)
실행 환경은 모두 Apple Silicon Mac(M1 Pro), PHP 8.5.6(password_hash 의 bcrypt 와 Argon2id, libsodium 1.0.22), Python 3.9.6, OpenSSL 4.1.0 입니다. 여기 나오는 '공격'은 전부 내 컴퓨터 안에서, 직접 만든 사용자와 직접 만든 20단어 목록을 대상으로 했습니다.
비밀번호 저장 5단계: 최악부터 정답까지
| 저장하는 값 | 예 | DB 를 훔친 사람이 얻는 것 |
|---|---|---|
| 평문 | saranghae1 |
모든 비밀번호를 즉시. 재사용했다면 다른 서비스 것까지 |
| 양방향 암호화 | 비밀번호의 AES | 키까지 새는 순간 전원 것(키는 대개 같은 서버에 있다) |
| 빠른 해시, 솔트 없음 | sha256("saranghae1") |
약한 비밀번호는 마이크로초 만에. 같은 비밀번호는 같은 해시로 드러남 |
| 빠른 해시 + 솔트 | sha256(salt + pw) |
약한 비밀번호는 여전히 마이크로초 만에. 한 명씩일 뿐 |
| 느린 비밀번호 해시 | bcrypt, Argon2id, scrypt, PBKDF2 | 추측 1회에 수십~수백 밀리초. 강한 비밀번호는 살아남음 |
용어 두 개를 먼저 정리합니다. 솔트는 사용자마다 다른 무작위 값으로, 비밀번호와 함께 해시하고 해시 옆에 저장합니다. 비밀이 아닙니다. 역할은 같은 비밀번호를 쓰는 두 사람이 서로 다른 해시를 갖게 해서, 공격자가 한 명씩 따로 공격하게 만드는 것입니다. 느린 비밀번호 해시(키 유도 함수, KDF 라고도 합니다)는 일부러 작업을 많이 반복하거나 메모리를 많이 쓰는 함수이고, 하드웨어가 빨라지면 올릴 수 있는 비용(cost) 설정이 있습니다. 반복으로 느리게 만드는 것을 정보보안기사 교재에서는 '키 스트레칭'이라고 부릅니다.
5분 만에 첫 password_hash
PHP 에는 5.5 부터 올바른 도구가 들어 있습니다. password_hash() 가 솔트를 만들고 bcrypt(또는 Argon2id)를 실행해서, 열 하나에 저장할 문자열 하나를 돌려줍니다.
<?php
$pw = 'tiger2026';
$h1 = password_hash($pw, PASSWORD_DEFAULT);
$h2 = password_hash($pw, PASSWORD_DEFAULT);
echo $h1, "\n", $h2, "\n";
var_dump($h1 === $h2); // 같은 비밀번호, 다른 솔트
var_dump(password_hash($pw, PASSWORD_DEFAULT) === $h1); // 초보자가 자주 하는 비교
var_dump(password_verify($pw, $h1), password_verify('tiger2025', $h1));
print_r(password_get_info($h1));
$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq
$2y$12$29U5HBehXh1Llpb/miK5W.itq90n0dJTPPg7FSeA77my08szzXrES
bool(false)
bool(false)
bool(true)
bool(false)
Array
(
[algo] => 2y
[algoName] => bcrypt
[options] => Array
(
[cost] => 12
)
)
문자열 읽는 법: $2y$ 는 bcrypt, 12 는 비용(2¹² 라운드), 이어지는 22글자가 솔트, 나머지가 해시입니다. 대조에 필요한 정보가 전부 안에 있습니다. 그래서 세 번째 줄이 중요합니다. 로그인 입력을 해시해서 문자열을 비교하면 안 됩니다. 저장값에서 솔트와 비용을 읽어 주는 password_verify() 를 호출합니다.
로컬 해독 데모: 솔트 없는 SHA-256 대 20단어 목록
먼저 옛날의 나쁜 방식을 실제 공격자와 같은 순서로 공격합니다. 사용자도 목록도 직접 만든 것이고, 컴퓨터 밖으로는 아무것도 나가지 않습니다.
# 로컬 전용 데모: 직접 만든 사용자와 20단어 목록
import hashlib, time
users = {'kim': 'saranghae1', 'lee': 'qwer1234', 'park': 'saranghae1', 'choi': 'V7#qLm2!pZ9x'}
words = ("password 123456 qwerty qwer1234 asdf1234 saranghae saranghae1 1q2w3e4r hangul daebak "
"fighting seoul2026 iloveyou admin welcome letmein hello summer dragon monkey").split()
leak = {u: hashlib.sha256(p.encode()).hexdigest() for u, p in users.items()} # bad old storage
for u, h in leak.items():
print(f"{u:7s} {h[:20]}...")
t = time.perf_counter_ns()
table = {hashlib.sha256(w.encode()).hexdigest(): w for w in words} # 20 hashes, once
for u, h in leak.items():
print(f"{u:7s} -> {table.get(h, '(not in list)')}")
print(f"{len(words)} words, {len(leak)} users, {(time.perf_counter_ns() - t)/1000:.0f} µs")
kim 169a90acc84601e38752...
lee 4d4f26369171994f3a46...
park 169a90acc84601e38752...
choi 3655812bce4e006ad2a4...
kim -> saranghae1
lee -> qwer1234
park -> saranghae1
choi -> (not in list)
20 words, 4 users, 15 µs
눈여겨볼 점이 두 가지입니다. kim 과 park 의 해시가 같아서, 공격자는 아무것도 풀기 전에 두 사람이 같은 비밀번호라는 것을 압니다. 그리고 목록은 한 번만 해시해서 모든 사용자에게 재사용했습니다. 이것을 거대한 규모로 미리 해 두는 것이 레인보 테이블 같은 사전 계산 표입니다.
사용자마다 16바이트 무작위 솔트를 붙이자 중복은 사라졌습니다(네 해시가 모두 달라짐). 하지만 공격자는 목록을 사용자마다 한 번씩 돌리면 그만입니다. SHA-256 호출 38번, 24마이크로초에 같은 세 사람 것이 나왔습니다. 솔트는 필요하지만 그것만으로는 부족합니다.
이번에는 같은 사용자를 password_hash()(bcrypt, 비용 12)로 저장하고 단어마다 password_verify() 로 공격합니다.
kim $2y$12$G4W6qt2OPOwRgqR7YCZEfecp1NAQI7clqqbc7IRt7hca7ESoJiQdK
lee $2y$12$S81HNJGLyB85e.bs/qBtnOvKUhhNtBQNlyR.umWVtNtR91r/LISCq
park $2y$12$toYTcp6BkcyPzc/KDO1LueprtpJFOEgHehSEeBZw7kWIaOVgBCwrC
choi $2y$12$GpvUIRe.78dHzkVA/rkaFeBoztla.a9IgpHI203eSxm8U9N6Igwda
kim -> saranghae1
lee -> qwer1234
park -> saranghae1
choi -> (not in list)
38 password_verify calls, 8.9 s
솔직하게 말하면 bcrypt 는 saranghae1 을 구하지 못했습니다. 어떤 목록이든 상위 20개 안에 드는 비밀번호는 해시가 무엇이든 깨집니다. bcrypt 가 바꾼 것은 가격입니다. 24마이크로초가 8.9초, 약 37만 배가 됐습니다. 수백만 단어짜리 실제 목록이라면 이 배율이 '몇 분'과 '몇 년'의 차이가 됩니다. choi 의 무작위 12자리 비밀번호는 두 공격 모두에서 찾지 못했습니다.
실측: 추측 1회의 비용
각 알고리즘을 코어 하나로 쟀습니다. PHP 행은 워밍업 1회 뒤 11회 실행한 중앙값(SHA-256 은 101회)입니다. scrypt 행과 PBKDF2 한 행은 openssl kdf 결과에서 프로세스 시작 시간 약 6 ms 를 뺐습니다.
같은 PBKDF2(HMAC-SHA256 60만 회)라도 PHP hash_pbkdf2() 는 794 ms, Python hashlib.pbkdf2_hmac() 은 178 ms, OpenSSL 은 66 ms 였습니다. 로그인은 느린 구현의 값을 치르고, 공격자는 빠른 구현을 씁니다. 반복 횟수는 내 서버의 체감이 아니라 가장 빠른 숫자를 기준으로 정하세요.
같은 데이터를 '코어 하나로 N번 추측하는 데 걸리는 시간'으로 바꾸면 이렇습니다.
| 저장 방식 (코어 1개, M1 Pro) | 추측/초 | 20단어 목록 | 100만 단어 목록 | 영문 소문자 8자리 전부(26⁸) |
|---|---|---|---|---|
| SHA-256 (openssl) | 7,890,000 | 2.5 µs | 0.13초 | 7.4시간 |
| PBKDF2-SHA256, 60만 회 (openssl) | 15.2 | 1.3초 | 18.3시간 | 437년 |
| Argon2id, 64 MiB, t=4 | 6.6 | 3.0초 | 1.8일 | 1,005년 |
| bcrypt, 비용 12 | 4.2 | 4.7초 | 2.7일 | 1,567년 |
| scrypt, N=2¹⁷, r=8 | 3.3 | 6.0초 | 3.5일 | 1,993년 |
실제 공격자는 GPU 와 여러 대의 컴퓨터를 쓰므로 모든 칸을 큰 수로 나눠서 읽어야 합니다(여기서는 재지 않았습니다). 중요한 것은 행 사이의 비율입니다. Argon2id 와 scrypt 에는 표에 안 보이는 방어가 하나 더 있습니다. 추측 한 번마다 64 MiB, 128 MiB 의 메모리가 따로 필요해서 GPU 의 병렬성이 잘 먹히지 않습니다.
로그인할 때 비용 올리기: password_needs_rehash
PHP 8.4 에서 bcrypt 기본 비용이 10 에서 12 로 올랐고, 몇 년 뒤에는 직접 또 올리고 싶어질 겁니다. 저장된 해시를 오프라인에서 다시 만들 수는 없습니다(평문이 없으니까요). 하지만 로그인에 성공하는 순간에는 평문을 잠깐 갖고 있습니다.
<?php
$users = ['alice' => password_hash('sunshine-river-42', PASSWORD_BCRYPT, ['cost' => 10])];
const DUMMY = '$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq';
function login(array &$users, string $name, string $pw): bool {
$hash = $users[$name] ?? null;
if ($hash === null) { // 없는 사용자도 같은 시간을 쓰게 한다
password_verify($pw, DUMMY);
return false;
}
if (!password_verify($pw, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$users[$name] = password_hash($pw, PASSWORD_DEFAULT);
echo "rehashed: ", substr($hash, 0, 7), " -> ", substr($users[$name], 0, 7), "\n";
}
return true;
}
foreach ([['alice', 'wrong'], ['alice', 'sunshine-river-42'], ['alice', 'sunshine-river-42'],
['mallory', 'x']] as [$u, $p]) {
$t = hrtime(true);
$ok = login($users, $u, $p);
printf("%-8s %-18s %-5s %6.1f ms\n", $u, $p, $ok ? 'OK' : 'FAIL', (hrtime(true) - $t) / 1e6);
}
alice wrong FAIL 59.6 ms
rehashed: $2y$10$ -> $2y$12$
alice sunshine-river-42 OK 299.0 ms
alice sunshine-river-42 OK 240.6 ms
mallory x FAIL 242.2 ms
시간 열에서 미묘한 함정이 보입니다. 없는 사용자용 더미 해시는 비용 12 인데 alice 의 옛 해시는 비용 10 이었습니다. 그래서 해시가 갱신되기 전까지는 '비밀번호 틀림'(59.6 ms)과 '사용자 없음'(242.2 ms)을 구별할 수 있었습니다. 더미는 실제 해시와 같은 비용으로 맞추세요. 시간 차이로 새는 정보는 강좌 6에서 자세히 다룹니다.
bcrypt 72바이트 제한 재현
bcrypt 는 비밀번호의 앞 72바이트만 읽습니다. PHP 8.5.6 도 아무 경고 없이 잘라 냅니다. 한글 패스프레이즈로 시험했습니다(유명한 문장은 목록에 들어 있으니 실제로는 쓰지 마세요).
<?php
$a = '동해물과 백두산이 마르고 닳도록 하느님이 보우하사 우리나라 만세';
printf("%d chars, %d bytes\n", mb_strlen($a), strlen($a));
$h = password_hash($a . ' A', PASSWORD_BCRYPT);
var_dump(password_verify($a . ' B', $h)); // bcrypt: 앞 72바이트만 본다
var_dump(password_verify(substr($a, 0, 72), $h));
$h = password_hash($a . ' A', PASSWORD_ARGON2ID);
var_dump(password_verify($a . ' B', $h)); // Argon2id: 입력 전체를 본다
35 chars, 91 bytes
bool(true)
bool(true)
bool(false)
끝이 달라도 통과했고, 앞 72바이트(= "동해물과 백두산이 마르고 닳도록 하느님이 보우하사 "까지)만으로도 통과했습니다. 영어라면 72글자지만 UTF-8 한글은 한 글자 3바이트라서 공백 없이 쓰면 24글자가 한계입니다. 긴 한글 패스프레이즈를 권하는 서비스일수록 이 제한에 걸립니다.
관련된 함정이 하나 더 있습니다. PHP 8.5.6 의 bcrypt 는 NUL 바이트를 거부합니다. 흔히 말하는 "날것의 SHA-256 으로 전처리" 방법은 약 12% 확률로 실패합니다. 무작위 32바이트에 \0 이 들어 있을 확률이 1 − (255/256)³² ≈ 11.8% 이기 때문입니다.
first candidate with NUL in raw sha256: user-pass-2 (byte 12)
ValueError: Bcrypt password must not contain null character
페퍼: DB 밖에 두는 비밀
페퍼(pepper)는 모든 비밀번호에 해시 전에 섞는 서버 쪽 비밀입니다. 솔트와 달리 DB 에 저장하지 않고 환경 변수, 비밀 관리 서비스, DB 서버가 읽을 수 없는 파일에 둡니다. DB 만 유출됐다면 공격자는 추측을 한 번도 시험할 수 없습니다. HMAC 과 Base64 를 함께 쓰면 위의 bcrypt 함정 두 개도 해결됩니다. 결과가 44글자 ASCII 라서 NUL 이 없고 72바이트보다 짧기 때문입니다.
<?php
$pepper = getenv('PW_PEPPER') ?: 'demo-only-pepper-change-me-32byt';
function prep(string $pw, string $pepper): string {
return base64_encode(hash_hmac('sha256', $pw, $pepper, true)); // 44 ASCII chars, no NUL
}
$a = '동해물과 백두산이 마르고 닳도록 하느님이 보우하사 우리나라 만세';
$h = password_hash(prep($a . ' A', $pepper), PASSWORD_DEFAULT);
var_dump(password_verify(prep($a . ' A', $pepper), $h));
var_dump(password_verify(prep($a . ' B', $pepper), $h)); // 72바이트 뒤의 차이도 거부
var_dump(password_verify(prep($a . ' A', 'stolen-db-no-pepper'), $h));
bool(true)
bool(false)
bool(false)
페퍼의 대가는 운영입니다. 잃어버리면 아무도 로그인하지 못하고, 바꾸려면 모든 사용자가 다시 로그인하기를 기다려야 합니다. 페퍼 없이 72바이트 제한이 없는 Argon2id 를 고르는 팀도 많고, 둘 다 합리적입니다. 합리적이지 않은 것은 페퍼를 저장소에 커밋하는 것입니다.
Python: 표준 라이브러리에 bcrypt 는 없다
Python 3.9 표준 라이브러리에는 bcrypt 도 Argon2 도 없습니다. 이 Mac 에서는 hashlib 이 LibreSSL 2.8.3 으로 빌드돼 있어 scrypt 도 없었습니다.
AttributeError: module 'hashlib' has no attribute 'scrypt'
ModuleNotFoundError: No module named 'bcrypt'
hashlib.pbkdf2_hmac 은 항상 있고, 60만 회 반복은 현재 OWASP 의 PBKDF2-HMAC-SHA256 권장치와 같습니다. PHP 처럼 알고리즘, 반복 횟수, 솔트를 해시와 함께 저장합니다.
import base64, hashlib, hmac, secrets
ITER = 600_000
def hash_password(pw: str) -> str:
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, ITER)
b = lambda x: base64.b64encode(x).decode()
return f"pbkdf2_sha256${ITER}${b(salt)}${b(dk)}"
def verify_password(pw: str, stored: str) -> bool:
algo, it, salt, dk = stored.split("$")
test = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), base64.b64decode(salt), int(it))
return hmac.compare_digest(test, base64.b64decode(dk))
s = hash_password("가을하늘-2026")
print(s)
print(verify_password("가을하늘-2026", s), verify_password("가을하늘-2025", s))
pbkdf2_sha256$600000$hmFV9gzFWWLMXfqM2OIGKA==$nyV8N58YABdw9lixHEk0a3SGeVEB4+SH3XlzFNRVC0Y=
True False
실제 프로젝트에서는 관리가 잘 되는 argon2 나 bcrypt 패키지를 추가하는 것이 보통입니다(여기서는 설치하지 않았습니다). Django 같은 프레임워크도 이 알고리즘$반복횟수$솔트$해시 모양으로 저장합니다.
흔한 실수: 무슨 일이 생기고 어떻게 고치나
1. 로그인 입력을 해시해서 문자열로 비교하기. 생기는 일: 호출할 때마다 새 솔트를 쓰므로 password_hash($pw) === $stored 는 항상 false 입니다(위에서 확인). 그러면 누군가 솔트를 빼서 '고쳐' 버립니다. 고치는 법: password_verify().
2. PHP 에서 해시를 == 로 비교하기.
string(32) "0e462097431906509019562988736854"
string(32) "0e830400451993494058024219903391"
bool(true) // md5('240610708') == md5('QNKCDZO')
bool(false) // ===
bool(false) // hash_equals()
둘 다 '0 × 10 의 몇 제곱'이라는 숫자로 보이기 때문에 PHP 8.5 에서도 == 는 숫자로 비교합니다. 고치는 법: 비밀번호는 password_verify(), 그 밖의 비밀 비교는 hash_equals().
3. DB 열이 너무 짧다. 생기는 일: bcrypt 는 60글자, Argon2id 는 이번 실행에서 97글자였고, PASSWORD_DEFAULT 는 앞으로 바뀔 수 있습니다. CHAR(60) 이면 미래의 해시가 조용히 잘립니다. 고치는 법: PHP 매뉴얼 권장대로 VARCHAR(255).
4. 비밀번호를 trim·소문자화하거나 짧게 제한하기. 생기는 일: 해시 전에 trim() 이나 strtolower() 를 하면 비밀번호가 조용히 약해지고, 16자 상한은 패스프레이즈를 막습니다. 고치는 법: 입력된 바이트를 그대로 쓰고, 최소 64자까지 허용하고(NIST SP 800-63B 지침), Unicode 정규화를 한다면 NFC 로 일관되게 합니다. Mac 에서 붙여 넣은 한글이 NFD 로 들어오는 경우도 있으니 가입과 로그인에서 같은 규칙을 쓰세요.
5. '없는 사용자'와 '비밀번호 틀림'의 메시지를 다르게 하기. 생기는 일: 어떤 이메일이 가입돼 있는지 공격자가 알게 됩니다. 고치는 법: 메시지는 하나로, 위 로그인 코드처럼 같은 비용으로 더미 대조를 합니다.
6. 로그인 시도 횟수를 제한하지 않기. 생기는 일: 느린 해시가 지키는 것은 유출된 DB 이지 운영 중인 로그인 폼이 아닙니다. 고치는 법: 계정별·IP 별 시도 제한과, 가입 시 유출된 비밀번호 확인. 느린 해시는 서버 CPU 도 먹는다는 점에도 주의하세요. 1회 237 ms 라면 코어 하나로 처리하는 로그인은 초당 약 4번입니다.
알고리즘과 비용 고르기
| 상황 | 선택 | 시작 파라미터 (최신 OWASP 지침 확인) |
|---|---|---|
| 새 PHP 프로젝트 | PASSWORD_ARGON2ID 또는 PASSWORD_DEFAULT(bcrypt) |
Argon2id m=19 MiB, t=2, p=1 이상. bcrypt 비용 10 이상, PHP 기본값 12 |
| 긴 패스프레이즈, 한글 입력 | Argon2id, 또는 HMAC + Base64 전처리 bcrypt | 위와 같음 |
| Python 표준 라이브러리만 | hashlib.pbkdf2_hmac |
SHA-256, 60만 회, 16바이트 솔트 |
| FIPS 등 준수 요건 | PBKDF2-HMAC-SHA256 | 60만 회 이상 |
| 기존 MD5/SHA-1 테이블 | 지금 password_hash(옛 md5) 로 감싸고, 다음 로그인에서 교체 |
어느 형식의 행인지 표시해 둔다 |
마지막으로 운영 환경과 같은 하드웨어에서 비용을 조정합니다. 피크 시간에 1회 100~500 ms 정도가 기준입니다.
정직한 한계와 버전 메모
- PHP 8.5.6, Python 3.9.6(LibreSSL 2.8.3), OpenSSL 4.1.0 으로 실행했습니다.
password_hash()는 PHP 5.5, Argon2i 는 7.2, Argon2id 는 7.3 에서 추가됐습니다. bcrypt 기본 비용은 PHP 8.4 에서 10 에서 12 로 바뀌었고, 8.5.6 에서 12 인 것을 확인했습니다. - NUL 바이트의
ValueError와 72바이트에서의 조용한 잘림은 PHP 8.5.6 에서만 확인했습니다. 옛 PHP 의 NUL 바이트 처리는 다릅니다(여기서는 실행하지 않았습니다). - 속도는 모두 M1 Pro 코어 하나 기준입니다. GPU 를 쓰는 공격자의 숫자는 빠른 해시에서는 훨씬 크고, 메모리를 많이 쓰는 해시에서는 그만큼 극적이지 않습니다. GPU 는 돌리지 않았습니다.
- OWASP 의 파라미터 권장값은 바뀝니다. 표의 숫자는 작성 시점 OWASP Password Storage Cheat Sheet 값입니다. 고르기 전에 다시 확인하세요.
- 공격은 모두 로컬 컴퓨터에서 직접 만든 사용자와 20단어 목록으로 했습니다. 실제 목록은 수백만 단어입니다.
연습 문제
1. 사용자 테이블에 솔트 없는 sha256(password) 로 저장한 1만 행이 있습니다. 모두에게 비밀번호 재설정을 요청하지 않고 오늘부터 지키려면 어떻게 할까요?
2. bcrypt 비용 12 실측값 236.7 ms 를 쓰면 코어 하나로 초당 몇 번의 로그인을 처리할 수 있나요? 시도 제한이 없는 로그인 API 에 대해 무엇을 말해 주나요?
3. 한 사용자가 가나다라마바사아자차카타파하 를 두 번 이어 붙인 패스프레이즈(28글자)를 정했습니다. bcrypt 는 어디부터 무시하나요?
정답
1. 옛 해시를 감쌉니다. password_hash(옛_sha256_16진수, PASSWORD_DEFAULT) 를 저장하고 그 행에 '감쌈' 표시를 합니다. 로그인할 때는 password_verify(hash('sha256', $pw), $stored) 로 대조하고, 성공하면 새 password_hash($pw) 를 저장하고 표시를 지웁니다. 모든 행이 즉시 보호되고, 사용자가 로그인할 때마다 정상 형식으로 옮겨 갑니다.
2. 1000 ÷ 236.7 ≈ 초당 4.2번입니다. 공격자는 그보다 훨씬 많은 요청을 보내 CPU 를 다 써 버릴 수 있으므로, 해시가 느려도 로그인 API 에는 시도 제한이 필요합니다.
3. 한글 음절은 UTF-8 에서 3바이트라 72바이트는 24글자입니다. 마지막 4글자 '카타파하'가 무시됩니다. PHP 8.5.6 에서 앞 24글자만으로 password_verify 가 통과하는 것도 확인했습니다. Argon2id 를 쓰거나 HMAC + Base64 로 전처리하세요.
다음 글
자주 묻는 질문
SHA-256 에 솔트를 붙이면 비밀번호 저장에 충분한가요?
충분하지 않습니다. 솔트는 미리 계산한 표를 무력화하고 같은 비밀번호를 다른 해시로 보이게 하지만, SHA-256 자체는 여전히 빠릅니다. PHP 는 M1 Pro 코어 하나로 솔트 붙은 SHA-256 을 초당 약 218만 번 계산했고, 20단어 목록으로도 약한 비밀번호를 24마이크로초 만에 모두 찾았습니다. bcrypt, Argon2id, scrypt, 반복 횟수가 충분한 PBKDF2 처럼 일부러 느린 해시가 필요합니다.
bcrypt 는 정말 72바이트 뒤를 무시하나요?
네. PHP 8.5.6 에서 91바이트짜리 한글 문장 끝에 ' A' 를 붙여 bcrypt 로 해시했더니, ' B' 로 바꾼 문장도, 앞 72바이트만 있는 문자열도 password_verify 를 통과했습니다. 한글은 UTF-8 에서 한 글자 3바이트라 24글자가 한계입니다. Argon2id 에는 이 제한이 없고, bcrypt 를 쓴다면 HMAC 과 Base64 로 전처리합니다.
기존 사용자의 해시를 더 높은 비용이나 Argon2id 로 옮기려면?
로그인할 때 password_verify() 가 성공한 직후, 현재 알고리즘과 설정으로 password_needs_rehash() 를 호출합니다. true 면 방금 검증한 평문 비밀번호를 다시 해시해 행을 갱신합니다. 실행 예에서는 비용 10 해시가 첫 로그인에서 비용 12 가 됐습니다. 로그인하지 않는 사용자는 옛 해시 그대로 남습니다.