예전에 SHA-256으로 저장한 비밀번호를 로그인할 때마다 bcrypt로 자동 변환했다
서비스 끊김 없이 비번 해시 마이그레이션
#보안 #bcrypt #인증 #백엔드
솔직히 초기에 비밀번호를 SHA-256으로 해싱해서 저장했었다. 지금 보면 안 좋은 선택이다. SHA-256은 너무 빨라서, 요즘 장비로는 무차별 대입이 오히려 쉬워진다. 비밀번호 저장엔 일부러 느리게 설계된 bcrypt 같은 걸 써야 한다. 문제는 이미 SHA-256으로 저장된 기존 사용자들이 있다는 거였다. 비밀번호 원문은 당연히 모르니까 한 번에 다 bcrypt로 바꿀 수가 없다. 이 글에선 서비스를 멈추지 않고 해시 방식을 갈아탄 방법을 정리해본다. 핵심 아이디어 비밀번호 원문을 알 수 있는 유일한 순간이 있다. 바로 사용자가 로그인할 때 다. 그 순간 입력받은 평문으로 검증에 성공하면, 그 평문을 bcrypt로 다시 해싱해서 저장하면 된다. 그러니까 "로그인에 성공한 사람부터 하나씩, 자기도 모르게" 새 방식으로 옮겨가는 거다. 자주 로그인하는 사람은 금방 옮겨가고, 안 오는 사람은 옛날 해시로 남아 있다가 다음에 로그인할 때 옮겨간다. 두 방식을 같이 검증하는 함수 먼저 bcrypt 해시인지 SHA-256 해시인지 구분해야 한다. bcrypt 해시는 항상 "$2"로 시작 한다는 특징이 있다. 이걸로 구분했다. const SALT_ROUNDS = 10; const sha256 = (s) = crypto.createHash("sha256").update(s).digest("hex"); const hashPassword = (plain) = bcrypt.hash(plain, SALT_ROUNDS); // 저장된 해시가 bcrypt면 bcrypt로, 아니면 레거시 SHA-256으로 검증 const verifyPassword = async (plain, stored) = { if (!stored) return false; if (stored.startsWith("$2")) return bcrypt.compare(plain, stored); return sha256(plain) === stored; // 레거시 SHA-256 }; 로그인 시점에 자동 재해싱 로그인 핸들러에선 verifyPassword로 검증을 통과한 뒤, 저장된 게 옛날 SHA-256이면 그 자리에서 bcrypt로 다시 해싱해 UPDATE 한다. const ok = await verifyPassword(password, user.pwd); if (!ok) { return res.status(401).json({ error: "비밀번호가 올바르지 않습니다." }); } // 레거시 SHA-256 해시였다면 bcrypt로 자동 마이그레이션 if (!user.pwd.startsWith("$2")) { const newHash = await hashPassword(password); await pool.query("UPDATE Users SET pwd = ? WHERE id = ?", [newHash, user.id]); } 사용자 입장에선 평소처럼 로그인했을 뿐인데, 뒤에서는 비밀번호 저장 방식이 조용히 업그레이드된다. 아무것도 안 바뀐 것처럼 보이는 게 포인트다. 덤: 해시를 응답에 흘리지 않기 하는 김에 사용자 정보를 응답이나 토큰에 담기 전에 비밀번호 해시 필드를 지우는 것도 챙겼다. 해시라고 해도 클라이언트로 내보낼 이유가 전혀 없다. delete user.pwd; // 응답/토큰에 해시 노출 방지 정리하면 레거시를 한 번에 갈아엎으려고 하면 거의 항상 무리가 따른다. 비밀번호처럼 원본을 모르는 데이터면 더더욱 그렇다. 이럴 땐 "사용자가 자연스럽게 거쳐가는 길목에서 조금씩 옮기는" 점진적 마이그레이션이 답이 된다. 지금도 로그를 보면 가끔 오래된 사용자가 로그인하면서 bcrypt로 옮겨가는 게 보인다. 서비스는 한순간도 멈추지 않았고, 사용자는 아무것도 몰랐다. 이런 게 잘 된 마이그레이션이라고 생각한다.