F12 누르면 비밀번호가 보인다 - 프론트엔드에서 SHA-256 해싱으로 로그인 평문 노출 막기

F12 누르면 비밀번호가 보인다 - 프론트엔드에서 SHA-256 해싱으로 로그인 평문 노출 막기

March 11, 2026

“비밀번호가 평문으로 노출되고 있으니 SHA-256으로 해싱하라.”

처음 받아본 보안 태스크였습니다. 해싱이나 암호화에 대해서는 알고있었지만, SHA-256이 정확히 뭔지는 몰랐습니다.
검색하면 salt, PBKDF2, 레인보우 테이블 같은 용어가 쏟아졌고, “이거 프론트엔드가 할 일인가?” 싶기도 했습니다. HTTPS가 있으니 전송 구간은 안전한 거 아닌가, 라고 생각했거든요.

결국 하나씩 질문하면서 답을 찾아갔습니다.

  • SHA-256이 대체 뭔가
  • salt 없이 해싱하면 위험하지 않나
  • 서버도 비밀번호를 모르나
  • 기존에 저장된 비밀번호는 어떻게 되나

로그인을 구현할 때 보안을 신경쓰고 싶은 프론트엔드 개발자에게 도움이 될까 싶어, 그 과정을 정리해보았습니다.


먼저, 문제가 뭐였나

사내 서비스가 GS 인증(정부 및 공공기관에 납품하기 위해 필요한 소프트웨어 품질 인증)을 앞두고 있었습니다. 보안 점검 과정에서 프론트엔드 팀에 떨어진 태스크가 이것이었습니다.

[로그인] 로그인 시 해당 계정 비밀번호가 노출되고 있다

F12를 열어 보면 상황이 바로 보입니다.

HTTPS는 네트워크 전송 구간의 암호화일 뿐, 브라우저 DevTools에서는 복호화된 요청 본문이 그대로 노출됩니다. 화면을 잠깐 들여다보거나 화면 공유 중이라면, 비밀번호가 그대로 보이는 것이죠. “전송 중 암호화”와 “요청 본문의 평문 노출”은 별개의 문제였습니다.

해결 방향은 태스크에 적혀 있었지만, 해싱이라는 개념이 익숙하지 않았기 때문에 구현보다 이해가 먼저였습니다.


SHA-256이 대체 뭔가

가장 먼저 부딪힌 질문이었습니다. 태스크에는 “SHA-256으로 해싱하라”고만 적혀 있었는데, 해싱이 뭔지부터 알아야 했습니다.

SHA-256은 어떤 텍스트를 넣으면 항상 같은 길이(64자리 hex 문자열)의 “지문”을 만들어주는 일방향 함수입니다. 같은 입력이면 항상 같은 결과가 나오지만, 결과로부터 원본을 역추적하는 것은 수학적으로 불가능합니다.

참고: SHA-2 - Wikipedia

"qwer3344@" → "f7c3bc1d808e04732adf679965ccc34ca7ae3441..."

암호화라고 하면 보통 “암호화 ↔ 복호화”를 떠올리는데, 해싱은 복호화가 없는 암호화입니다. 원본을 알면 해시를 구할 수 있지만, 해시만 보고 원본을 알아내는 건 불가능하죠. 그래서 네트워크 탭에 해시값이 찍히더라도 비밀번호 자체는 노출되지 않습니다.

그런데 “같은 입력이면 항상 같은 결과” 라는 성질이 오히려 걸렸습니다. 누군가 흔한 비밀번호들의 해시값을 미리 모아두면, 해시만 보고 원본을 찾아낼 수 있는 거 아닌가.

실제로 SHA-256 해싱을 조사하면서 “salt 없이 해싱하면 위험하다”는 경고를 자주 봤습니다.


salt 없이 해싱하면 위험하지 않나

사실 이 질문 때문에 한동안 구현을 시작하지 못했습니다. “salt 없이 SHA-256만 쓰면 레인보우 테이블 공격에 취약하다”는 글을 읽고, 태스크의 방향 자체가 불완전한 건 아닌가 의심했거든요.

참고: Password Storage Cheat Sheet - OWASP

레인보우 테이블이란 흔한 비밀번호들을 미리 해싱해둔 “정답지” 같은 것입니다.

"password123" → "ef92b778..."
"qwer3344@"  → "a1b2c3d4..."

공격자가 이 테이블을 가지고 있으면, 해시값을 역으로 찾아볼 수 있습니다. salt(임의의 랜덤 문자열)를 비밀번호에 덧붙여 해싱하면, 같은 비밀번호라도 완전히 다른 해시가 나오기 때문에 테이블이 무력화됩니다.

그런데 이걸 조금 더 들여다보니, 프론트엔드에서는 이게 문제가 되지 않는다는 걸 알게 되었습니다. 프론트엔드와 서버는 해싱의 목적 자체가 다르기 때문입니다.

  • 프론트엔드 해싱의 목적: 브라우저 네트워크 탭에서 평문 비밀번호가 그대로 보이는 것을 막는 것
  • 서버 해싱의 목적: DB가 유출되더라도 비밀번호 원본을 보호하는 것 (salt + PBKDF2)

레인보우 테이블 공격이 위험한 건 DB에 저장된 해시가 유출될 때이고, 그건 서버가 salt로 방어합니다. 프론트엔드는 네트워크 구간의 평문 노출만 막으면 되기 때문에, salt 없는 SHA-256으로 충분했습니다.

📌 프론트엔드는 “네트워크 구간에서 평문을 숨기는 것”, 서버는 “DB 유출에 대비하는 것”.
이 역할 분리를 이해하고 나니 salt 없는 SHA-256이 프론트엔드에서 왜 괜찮은지 납득이 되었고, 이후의 기술 선택과 배포 판단에서도 이 구분이 계속 기준이 되었습니다.

돌아보면 이 의문에 매달린 시간이 아까웠을 수 있지만, 이때 “프론트엔드와 서버가 각각 뭘 책임지는지”를 정리한 것이 결국 이후 구현, 배포, 마이그레이션까지 모든 판단의 출발점이 되었습니다.

그렇다면 서버는 이 해시값을 받아서 어떻게 처리하는 걸까. 궁금해서 서버 개발자에게 물어봤습니다.


서버도 비밀번호를 모르나

이전에 네이버 같은 사이트에서 비밀번호를 변경할 때, “기존 비밀번호는 저희도 알 수 없습니다”라는 안내를 본 적이 있었습니다.
그때는 “입력받는데 왜 모르지?” 싶었는데, 이번에 해싱을 이해하고 나니 그 이유를 알게 되었습니다.

결론부터 말하면, 맞습니다. 서버도 원본 비밀번호를 모릅니다.

서버가 받는 건 이미 SHA-256으로 해싱된 값이고, 그걸 다시 PBKDF2(salt + 반복 연산)로 해싱해서 DB에 저장합니다. 어느 단계에서도 원본 비밀번호는 존재하지 않습니다.

1. 사용자 입력              "qwer3344@"
2. 프론트엔드 SHA-256       "a1b2c3d4..."
3. 서버 PBKDF2            "x9y8z7..."
4. DB 저장값과 비교          "x9y8z7..." === "x9y8z7..." → 로그인 성공

로그인 검증은 “같은 입력은 같은 해시를 만든다”는 성질을 이용한 비교 방식입니다. 비밀번호 원본을 비교하는 게 아니라 해시값을 비교하는 것이죠. 사용자가 올바른 비밀번호를 입력하면 같은 해시 체인을 거쳐 DB에 저장된 값과 동일한 결과가 나오고, 틀리면 다른 결과가 나옵니다.

이 구조를 서버 개발자에게 직접 확인하고 나니 구현을 시작할 수 있었습니다. 하지만 한 가지 선택이 더 필요했습니다. 프론트엔드에서 쓸 해싱 알고리즘으로 SHA-256이 정말 맞는 건지, PBKDF2 같은 더 강력한 것을 써야 하는 건 아닌지 확인하고 싶었습니다.


SHA-256을 선택한 이유와 적용

왜 SHA-256인가

해싱 알고리즘이 여러 가지 있다는 걸 알게 되면서, 왜 하필 SHA-256인지 궁금해졌습니다.
PBKDF2라는 것도 있는데, 이건 비밀번호 보호에 더 적합하다고들 하거든요.

SHA-256PBKDF2
속도빠름의도적으로 느림
용도데이터 무결성 검증, 지문 생성비밀번호 저장 (brute-force 방어)
프론트엔드 적합성빠르기 때문에 UX에 영향 없음느려서 로그인 시 딜레이 발생

PBKDF2는 “의도적으로 느린” 해싱입니다. 공격자가 수천만 개의 비밀번호를 시도하는 brute-force 공격을 어렵게 만들기 위해 연산을 반복합니다. 이건 DB 저장 시에 필요한 특성이지, 사용자가 로그인 버튼을 누를 때마다 겪어야 할 이유는 없습니다.

앞서 정리한 “프론트엔드는 평문 노출만 막으면 된다”는 역할 구분에 따르면, 빠른 SHA-256이 적합했습니다.
저희 팀에서는 이 판단이 맞다고 봤지만, 만약 프론트엔드 단에서도 brute-force 방어가 필요한 환경이라면 다른 선택이 나올 수도 있을 것 같습니다.

Web Crypto API로 구현하기

별도 라이브러리 없이, 브라우저 내장 Web Crypto API로 구현할 수 있었습니다.

async function hashPassword(password) {
  const encoder = new TextEncoder();
  const data = encoder.encode(password);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

TextEncoder로 문자열을 바이트 배열로 변환하고, crypto.subtle.digest로 SHA-256 해시를 생성한 뒤, 결과를 16진수 문자열로 변환합니다. Web Crypto API는 모든 모던 브라우저에서 지원되기 때문에 별도의 polyfill도 필요 없었습니다.

기존 로그인 API 호출부에 이 함수를 끼워 넣으면 됩니다.

// Before
const response = await api.post('/login', {
  user_id: email,
  password: rawPassword, // 평문
});

// After
const hashedPw = await hashPassword(rawPassword);
const response = await api.post('/login', {
  user_id: email,
  password: hashedPw, // SHA-256 해시값
});

수정해야 할 곳은 비밀번호를 서버에 전송하는 모든 곳이었습니다. 저희 서비스에서는 로그인과 회원가입 두 곳이었습니다.

서버와 배포 타이밍 협의

코드 자체는 간단했지만, 배포에서 한 가지 중요한 문제가 있었습니다. 이미 운영을 하고 있는 서비스이기 때문에, 프론트엔드만 먼저 해싱해서 보내면 서버가 평문을 기대하고 있어 로그인이 깨집니다.
반대로 서버가 먼저 해시값 처리를 배포하면, 아직 평문을 보내는 프론트엔드 때문에 역시 로그인이 깨집니다.

서버가 해시값을 받을 준비가 된 후에 프론트엔드를 배포해야 했습니다.
이 순서를 맞추기 위해 서버 개발자와 배포 일정을 사전에 맞췄습니다. 코드 몇 줄짜리 작업이었지만, 순서를 틀리면 전체 로그인이 멈추는 상황이었기 때문에 이 협의가 실제로 가장 긴장되는 부분이었습니다.

📌 이 배포 순서 문제는 다음 장에서 다루는 “기존 비밀번호 마이그레이션”과도 직접 연결됩니다.
서버가 해시값을 받을 준비를 한다는 건 단순히 입력 형식만 바꾸는 게 아니라, 기존 유저의 로그인까지 고려한 로직을 함께 배포한다는 뜻이거든요.

배포 순서가 정해지고 나서야, 한 가지 더 궁금한 게 생겼습니다. 기존에 이미 가입한 유저들의 비밀번호는 어떻게 되는 걸까.


기존 비밀번호는 어떻게 되나

이건 프론트엔드보다는 서버 영역의 문제이지만, 프론트엔드 개발자도 이 흐름을 알고 있어야 배포 후 로그인 관련 이슈가 생겼을 때 원인을 빠르게 짚을 수 있습니다.
핵심은 입력값 자체가 달라진다는 점이었습니다.

기존 방식변경 후
프론트엔드가 보내는 값평문 ("qwer3344@")SHA-256 해시 ("a1b2c3d4...")
DB 저장 방식PBKDF2(평문)PBKDF2(SHA-256(평문))

아무 조치 없이 배포하면 기존 유저의 로그인이 전부 깨질 수 있습니다.
앞서 “서버가 해시값을 받을 준비를 한다”고 했는데, 그 준비에는 이 마이그레이션 로직이 포함되어 있었습니다. 일반적인 해결법은 점진적 마이그레이션입니다.

로그인 요청 시 서버의 처리 흐름

1) 새 방식 검증: PBKDF2(SHA-256(입력값)) vs DB값
2) 실패하면 → 구 방식 검증: PBKDF2(입력값) vs DB값
3) 구 방식으로 성공하면 → DB를 새 방식으로 업데이트

유저가 로그인하는 순간, 자연스럽게 새 방식으로 마이그레이션되는 구조입니다. 처음 로그인할 때는 구 방식으로 검증되지만, 그 즉시 DB가 새 방식으로 갱신되기 때문에 다음 로그인부터는 새 방식으로 처리됩니다. 강제 비밀번호 재설정 없이 유저 경험을 해치지 않는 방법이죠.

여기까지 준비가 끝나고, 서버와 순서를 맞춰 배포했습니다. 그 결과가 어땠는지 확인해 봤습니다.


적용 결과

적용 후 F12 네트워크 탭을 다시 열어보니, 요청 페이로드가 달라져 있었습니다.

비밀번호 대신 64자리 해시값이 표시됩니다. 화면을 들여다봐도, 화면 공유 중이더라도, 원본 비밀번호는 알 수 없습니다. 로그인과 회원가입 모두 정상적으로 동작했고, 잘못된 비밀번호 입력 시 로그인 실패도 정상 처리되었습니다.


마무리

돌아보면 코드는 10줄도 안 됐지만, “이 단계에서 뭘 보호하려는 건지”를 이해하는 데 시간이 더 걸렸습니다. 프론트엔드와 서버의 역할 구분을 잡고 나니 나머지 판단은 자연스럽게 따라왔고, 실제로 가장 긴장된 건 서버 개발자와 배포 순서를 맞추는 과정이었습니다.

프론트엔드 해싱만으로 모든 보안 문제가 해결되지는 않습니다.
레인보우 테이블이나 brute-force 방어는 여전히 서버의 영역이고, 보안 감사에서 추가 요구사항이 나올 수도 있습니다. 그래도 보안 태스크가 처음이라 어디서부터 시작할지 모르겠다면, “이 단계에서 뭘 보호하려는 건지”부터 질문해 보시길 권합니다.


용어 정리

  • 해싱(Hashing): 임의 길이의 데이터를 고정 길이의 값으로 변환하는 것. 복호화가 불가능한 일방향 변환입니다.
  • SHA-256: 해싱 알고리즘의 한 종류. 어떤 입력이든 256비트(64자리 hex 문자열) 길이의 해시값을 생성합니다.
  • salt: 해싱 전에 비밀번호에 덧붙이는 임의의 랜덤 문자열. 같은 비밀번호라도 다른 해시값이 나오게 만들어, 레인보우 테이블 공격을 무력화합니다.
  • PBKDF2: Password-Based Key Derivation Function 2. 해싱 연산을 수천~수만 번 반복하여 의도적으로 느리게 만든 알고리즘입니다. 공격자의 brute-force 시도를 어렵게 만드는 것이 목적이며, 주로 서버에서 DB 저장 시 사용합니다.
  • 레인보우 테이블(Rainbow Table): 흔한 비밀번호들을 미리 해싱해둔 대조표. 해시값을 이 표에서 역으로 찾아 원본 비밀번호를 알아내는 공격 방식에 사용됩니다.
  • brute-force 공격: 가능한 모든 비밀번호 조합을 하나씩 시도하여 맞는 것을 찾아내는 공격 방식.