예전에 SHA-256으로 저장한 비밀번호를 로그인할 때마다 bcrypt로 자동 변환했다
SHA-256으로 저장해둔 비밀번호를 서비스를 멈추지 않고 bcrypt로 옮겼다. 로그인에 성공하는 순간 다시 해싱하는 방식과, 레거시 검증 코드를 언제 지울 수 있는지에 대한 고민을 적었다.
#보안 #bcrypt #인증 #백엔드
솔직히 초기에는 비밀번호를 SHA-256으로 해싱해서 저장했다. 지금 보면 안 좋은 선택이다. SHA-256은 원래 빠르라고 만든 해시라, 요즘 장비로는 비밀번호 후보를 초당 수억 개씩 돌려볼 수 있다. 비밀번호 저장에는 일부러 느리게 설계된 bcrypt 같은 걸 써야 한다는 걸 나중에 알았다. 얼마나 차이가 나는지 궁금해서 내 서버에서 직접 재봤다. sha256 x1000: 4.6 ms (한 번에 0.005ms) bcrypt hash rounds=10: 78 ms bcrypt compare rounds=10: 72 ms bcrypt hash rounds=12: 275 ms SHA-256은 천 번을 돌려도 5ms가 안 걸리는데 bcrypt는 한 번에 78ms다. 공격자 입장에서 보면 같은 시간에 시도해볼 수 있는 비밀번호 수가 만 배 넘게 줄어드는 셈이다. 정상 사용자는 로그인할 때 한 번만 치르는 비용이라 체감이 안 된다. 문제는 이미 SHA-256으로 저장된 사용자들이 있다는 거였다. 해시는 되돌릴 수 없으니 원래 비밀번호를 모르고, 그러니 한 번에 다 bcrypt로 바꿔 넣을 수가 없다. 그렇다고 전부 비밀번호를 다시 설정하라고 하는 건 사용자한테 불편을 떠넘기는 일이라 하고 싶지 않았다. 이 글에서는 서비스를 멈추지 않고, 사용자가 눈치채지 못하게 해시 방식을 갈아탄 방법을 정리해본다. 비밀번호 원문을 알 수 있는 순간은 딱 한 번이다 바로 사용자가 로그인할 때다. 그 순간에는 입력받은 평문이 서버에 잠깐 있다. 옛날 방식으로 검증해서 맞으면, 그 평문을 bcrypt로 다시 해싱해서 저장하면 된다. 그러니까 로그인에 성공한 사람부터 하나씩, 자기도 모르게 새 방식으로 옮겨가는 거다. 자주 들어오는 사람은 금방 옮겨가고, 안 들어오는 사람은 옛날 해시로 남아 있다가 다음에 로그인할 때 옮겨간다. 서비스를 멈출 일도 없고 안내 메일을 보낼 일도 없다. 두 방식을 어떻게 구분하나 저장된 값이 bcrypt 해시인지 SHA-256 해시인지부터 구분해야 한다. 다행히 생긴 게 완전히 다르다. SHA-256은 16진수 64자리가 쭉 이어진 문자열이고, bcrypt 해시는 항상 이런 모양이다. $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy ^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 버전 비용 솔트(22자) 해시(31자) 맨 앞의 $2b가 bcrypt 버전이고, 그다음 10이 비용(cost)이다. 이 값이 1 늘 때마다 계산량이 두 배가 된다. 위에서 잰 대로 10이면 78ms, 12면 275ms다. 솔트가 해시 안에 같이 들어 있어서 따로 저장할 컬럼도 필요 없다. 그래서 저장된 값이 $2로 시작하면 bcrypt, 아니면 옛날 SHA-256으로 보면 된다. // db/auth.js 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 }; SALT_ROUNDS는 10으로 뒀다. 12로 올리면 더 안전하긴 한데 로그인 한 번에 300ms 가까이 붙는다. 개인 프로젝트 서버 한 대에서 그 정도면 좀 과하다고 봤다. bcrypt 문서에서도 10을 기본값으로 권한다. 로그인 시점에 자동으로 다시 해싱한다 로그인 핸들러에서는 verifyPassword로 검증을 통과한 뒤, 저장된 게 옛날 SHA-256이면 그 자리에서 bcrypt로 다시 해싱해 UPDATE 한다. 실제 코드는 이렇다. // db/auth.js router.post("/emailLogin", loginLimiter, async (req, res) = { const { email, password } = req.body; const [rows] = await pool.query( "SELECT id, email, nickname, role, pwd FROM Users WHERE email = ? AND social_type = 0", [email] ); if (rows.length === 0) { return res.status(401).json({ error: "이메일 또는 비밀번호가 올바르지 않습니다." }); } const user = rows[0]; 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; // 응답/토큰에 해시 노출 방지 const token = generateToken(user); return res.json({ token, user }); }); 사용자 입장에서는 평소처럼 로그인했을 뿐인데, 뒤에서는 저장 방식이 조용히 바뀐다. 옛날 해시로 들어오는 그 한 번만 SHA-256 검증에 bcrypt 해싱 78ms가 더 붙고, 그다음부터는 bcrypt 비교 72ms만 든다. 어느 쪽이든 사람이 느낄 수 있는 시간은 아니다. 이메일이 없을 때와 비밀번호가 틀렸을 때 에러 메시지를 같은 문장으로 둔 것도 일부러 그런 거다. "그런 이메일은 없다"고 따로 알려주면, 가입된 이메일인지 아닌지를 밖에서 알아낼 수 있다. loginLimiter도 같은 이유로 붙였다. 한 IP에서 로그인을 너무 자주 시도하면 잠깐 막는다. 덤으로, 해시를 응답에 흘리지 않기 하는 김에 사용자 정보를 응답이나 토큰에 담기 전에 비밀번호 해시 필드를 지우는 것도 챙겼다. 위 코드의 delete user.pwd가 그거다. 해시라고 해도 클라이언트로 내보낼 이유가 전혀 없고, JWT는 base64라 안에 뭐가 들었는지 누구나 열어볼 수 있다. 예전 코드는 SELECT *로 가져온 행을 그대로 토큰에 넣고 있어서 해시가 토큰 안에 들어 있었다. 언제 지울 수 있을까 이 방식의 문제는 끝나는 시점을 내가 정할 수 없다는 거다. 지금도 코드에 이게 남아 있다. // SHA256 헬퍼: 레거시 비번 검증용으로만 남겨둔다(신규/업그레이드는 bcrypt). const sha256 = (s) = crypto.createHash('sha256').update(s).digest('hex'); 한 명이라도 아직 로그인을 안 한 사용자가 남아 있으면 이 줄을 못 지운다. 그 사람이 영영 안 들어올 수도 있는데, 안 들어온 건지 그냥 아직인지 구분할 방법이 없다. 언제까지 기다릴지는 결국 정해야 한다. 다만 무작정 기한을 긋기 전에 남은 계정이 몇 개인지부터 세어볼 생각이다. 해시 모양이 다르니 쿼리 하나면 된다. SELECT COUNT(*) FROM Users WHERE social_type = 0 -- 이메일 가입자만 (카카오 로그인은 비밀번호가 없다) AND pwd NOT LIKE '$2%'; -- 아직 SHA-256인 계정 손에 꼽을 정도면 일괄 재설정보다 개별로 안내하는 쪽이 낫고, 그 뒤에 sha256 헬퍼를 지우면 된다. 그리고 옛날 로그인 코드가 하나 더 남아 있었다 이 글을 다시 정리하면서 프로젝트를 뒤져보니 db/user.js에 예전 로그인 라우트가 그대로 있었다. 이런 코드다. // db/user.js (옛날 코드) router.post('/doLogin', async (req, res) = { const hashedPassword = crypto.createHash('sha256').update(password).digest('hex'); const query = 'SELECT * FROM Users WHERE email = ? AND pwd = ?'; connection.query(query, [email, hashedPassword], (err, results) = { ... }); }); SHA-256으로 해싱해서 DB의 값과 그대로 비교한다. 이 경로로 로그인하면 bcrypt로 바뀐 계정은 전부 실패한다. 다행히 app.js에서 이 라우터를 물리지 않아서 실제로는 안 불리는 코드였는데, 누가 나중에 다시 붙이면 그때부터 로그인이 깨진다. 이런 건 안 쓰는 걸 확인한 김에 지워두는 게 맞다. 비밀번호 해시를 바꿔야 하는데 기존 사용자가 있다면, 로그인 시점에 옮기는 이 방식을 한 번쯤 써볼 만하다. 코드 몇 줄이면 되고 사용자한테는 아무 부담이 없다. 대신 옛날 방식으로 검증하는 코드가 다른 데 더 남아 있지 않은지는 꼭 같이 뒤져보길 권한다.