웹 애플리케이션에서 로그인 기능을 구현할 때, HTTPS를 사용하고 있으니 비밀번호 전송이 안전하다고 생각하기 쉽다. 물론 HTTPS는 전송 구간을 암호화해주지만, 브라우저 개발자 도구의 Network 탭을 열어보면 이야기가 달라진다.
POST /api/auth/login
{
"email": "[email protected]",
"password": "mySecret123!" ← 평문 그대로 노출
}
요청 페이로드에 비밀번호가 그대로 찍힌다. 화면 공유 중이거나, 프록시 로깅이 켜져 있거나, 브라우저 확장 프로그램이 요청을 가로채는 상황이라면 비밀번호가 의도치 않게 유출될 수 있다.
RSA 비대칭 암호화를 활용하여, 클라이언트에서 비밀번호를 암호화한 후 서버에 전송하는 방법을 정리해본다.
RSA
비대칭 암호화(공개키 암호화)의 개념은 이렇다.
- 공개키 — 누구나 가질 수 있고, 암호화에 사용
- 개인키 — 서버만 보유하고, 복호화에 사용
공개키가 노출되더라도 개인키 없이는 복호화할 수 없으므로, 클라이언트에 공개키를 자유롭게 내려줄 수 있다. AES 같은 대칭키 방식은 암호화와 복호화에 같은 키를 사용하기 때문에, 클라이언트에 키를 전달하는 것 자체가 보안 문제가 된다.
전체 흐름

구현
1단계: RSA 키 쌍 생성 (서버)
애플리케이션 시작 시 RSA 2048-bit 키 쌍을 메모리에 생성한다.
public final class RsaKeyHolder {
private static final KeyPair KEY_PAIR;
static {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KEY_PAIR = generator.generateKeyPair();
} catch (Exception e) {
throw new RuntimeException("RSA 키 쌍 생성 실패", e);
}
}
public static String getPublicKeyBase64() {
return Base64.getEncoder().encodeToString(
KEY_PAIR.getPublic().getEncoded()
);
}
}
static 초기화 블록을 사용해서 클래스 로딩 시점에 한 번만 키를 생성한다.
애플리케이션이 재시작되면 새로운 키 쌍이 만들어지므로, 별도의 키 파일 관리가 필요 없다.
2단계: 공개키 API (서버)
클라이언트가 암호화에 사용할 공개키를 요청하는 엔드포인트를 추가한다.
@GetMapping("/public-key")
fun getPublicKey(): PublicKeyResponse {
return PublicKeyResponse.of(RsaKeyHolder.getPublicKeyBase64())
}
이 엔드포인트는 인증 없이 접근할 수 있어야 한다. 로그인 전에 호출되므로.
3단계: 비밀번호 암호화 (클라이언트)
브라우저의 Web Crypto API를 사용하여 비밀번호를 암호화한다. 별도의 외부 라이브러리 없이 브라우저 내장 API만으로 구현할 수 있다.
async function doLogin() {
var email = document.getElementById('login-email').value.trim();
var password = document.getElementById('login-password').value;
// 1. 서버에서 공개키 가져오기
var keyRes = await fetch('/api/auth/public-key');
var keyData = await keyRes.json();
// 2. Base64 문자열 → ArrayBuffer → CryptoKey 변환
var binaryKey = Uint8Array.from(
atob(keyData.publicKey),
function(c) { return c.charCodeAt(0); }
);
var cryptoKey = await crypto.subtle.importKey(
'spki',
binaryKey.buffer,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
);
// 3. 비밀번호를 RSA-OAEP로 암호화
var encrypted = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
cryptoKey,
new TextEncoder().encode(password)
);
// 4. 암호화된 결과를 Base64로 인코딩하여 전송
var encryptedBase64 = btoa(
String.fromCharCode.apply(null, new Uint8Array(encrypted))
);
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: encryptedBase64 })
});
}
핵심은 crypto.subtle.importKey로 서버의 공개키를 가져온 뒤, crypto.subtle.encrypt로 RSA-OAEP 암호화를 수행하는 부분이다.
spki는 서버가 내려준 공개키의 포맷(X.509 SubjectPublicKeyInfo)을 의미한다.
4단계: 복호화 및 인증 (서버)
서버에서는 전달받은 암호화된 비밀번호를 개인키로 복호화한 후 Spring Security 인증을 수행한다.
public static String decrypt(String encryptedBase64) {
try {
PrivateKey privateKey = KEY_PAIR.getPrivate();
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
OAEPParameterSpec oaepParams = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams);
byte[] decryptedBytes = cipher.doFinal(
Base64.getDecoder().decode(encryptedBase64)
);
return new String(decryptedBytes);
} catch (Exception e) {
throw new RuntimeException("RSA 복호화 실패", e);
}
}
// AuthService.kt
fun getSecurityContext(loginRequest: LoginRequest): SecurityContext {
val decryptedPassword = RsaKeyHolder.decrypt(loginRequest.password)
val authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken(loginRequest.email, decryptedPassword)
)
// ...
}
기존 코드에서 변경되는 부분은 단 한 줄, loginRequest.password를 RsaKeyHolder.decrypt(loginRequest.password)로 바꾸는 것이다.
주의: Java와 Web Crypto API의 OAEP 파라미터 불일치
구현 과정에서 처음에 OAEPParameterSpec 없이 단순하게 작성하면,
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
BadPaddingException: Padding error in decryption이 발생한다.
원인은 Java와 Web Crypto API가 사용하는 기본 해시 알고리즘이 다르기 때문이다.
| OAEP 해시 | MGF1 해시 | |
|---|---|---|
| Java 기본값 | SHA-256 | SHA-1 |
| Web Crypto API | SHA-256 | SHA-256 |
Java에서 OAEPWithSHA-256AndMGF1Padding을 지정하면 OAEP 해시는 SHA-256을 사용하지만, MGF1 해시는 기본값인 SHA-1을 사용한다.
반면 Web Crypto API에서 hash: 'SHA-256'을 지정하면 OAEP 해시와 MGF1 해시 모두 SHA-256을 사용한다.
이 불일치가 복호화 실패를 일으킨다. 해결 방법은 OAEPParameterSpec으로 MGF1 해시를 명시적으로 SHA-256으로 지정하는 것.
// ✗ 이렇게 하면 MGF1이 SHA-1을 사용 → Web Crypto API와 불일치
cipher.init(Cipher.DECRYPT_MODE, privateKey);
// ✓ MGF1도 SHA-256으로 명시 → Web Crypto API와 일치
OAEPParameterSpec oaepParams = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams);
적용 결과
적용 후 Network 탭을 확인하면, 비밀번호가 더 이상 평문으로 노출되지 않는다.
POST /api/auth/login
{
"email": "[email protected]",
"password": "hK7xJ9v2mQ4pR8sT..." ← RSA 암호화된 문자열
}
이 방식의 한계점
- HTTPS를 대체하지 않는다. HTTPS 없이 이 방식만으로는 중간자 공격(MITM)을 방어할 수 없다. 공개키 자체가 변조될 수 있다. 이 방식은 HTTPS 위에 추가하는 방어 계층이다.
- 리플레이 공격에 취약하다. 암호화된 비밀번호를 탈취하여 그대로 재전송하면 로그인이 된다. 이를 방지하려면 타임스탬프나 nonce를 함께 암호화하는 방법을 고려할 수 있다.
- 서버 재시작 시 키가 변경된다. 메모리에 키를 보유하므로, 재시작하면 이전 키로 암호화된 요청은 복호화할 수 없다. 로그인 요청은 실시간으로 처리되므로 실질적인 문제는 없지만, 인지하고 있어야 한다.
HTTPS가 전송 구간을 보호해주지만, 클라이언트 측에서 한 단계 더 암호화를 적용하면 개발자 도구 노출, 로깅, 프록시 등 다양한 경로에서의 비밀번호 유출 위험을 줄일 수 있다. Web Crypto API 덕분에 외부 라이브러리 없이도 브라우저에서 RSA 암호화를 구현할 수 있어 적용 부담도 크지 않다.