이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 로그인 흐름을 credential 수신부터 세션 발급까지 어떤 단계로 나누어야 하는가?
- 계정 열거와 credential stuffing을 줄이려면 실패 응답과 로그를 어떻게 설계해야 하는가?
- Spring 백엔드에서 credential 검증 결과를 사용자 응답과 운영 로그로 어떻게 분리하는가?
개요
로그인은 단일 POST /login이 아니다.
실무 흐름은 보통 다음 단계로 나뉜다.
1. identifier와 credential 수신
2. 형식 검증
3. 계정 후보 조회
4. password hash 또는 credential 검증
5. 계정 상태 확인
6. 위험 평가
7. MFA 또는 step-up 필요 여부 판단
8. session 발급
9. 인증 성공/실패 이벤트 기록각 단계가 공격면이다. 특히 실패 응답, 응답 시간, 로그 필드가 계정 존재 여부를 새게 만들 수 있다.
공격 시나리오
공격자는 유출된 이메일과 비밀번호 목록을 가지고 있다.
먼저 계정 열거를 한다.
- 존재하지 않는 이메일에는 “계정을 찾을 수 없습니다”가 나온다.
- 존재하는 이메일에는 “비밀번호가 틀렸습니다”가 나온다.
- 잠긴 계정에는 “잠긴 계정입니다”가 나온다.
그다음 credential stuffing을 한다.
- 여러 IP에서 낮은 빈도로 시도한다.
- 같은 비밀번호를 많은 계정에 뿌린다.
- 성공한 계정은 즉시 세션을 발급받아 프로필과 결제 정보를 조회한다.
로그인이 성공하거나 실패하는 문제만이 아니다. 실패 응답이 공격자의 지도 역할을 한다.
취약한 요청/응답 예시
POST /login HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"email": "admin@example.com",
"password": "Spring2026!"
}HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"code": "PASSWORD_MISMATCH",
"message": "admin@example.com 계정은 존재하지만 비밀번호가 다릅니다",
"failedCount": 4
}응답 code와 message, 실패 횟수는 공격자에게 계정 존재 여부와 다음 시도 전략을 알려 준다.
주제별 핵심 판단
- 로그인은 credential 검증, 계정 상태 확인, 세션 발급, 실패 기록이 한 흐름으로 이어져야 한다.
- 실패 응답은 계정 존재 여부를 숨기되, 서버 로그는 원인별로 분리되어야 한다.
개선된 요청/응답 예시
사용자 응답은 단순하게 유지한다.
- 개선된 응답은
invalid_credentials처럼 일반화하고, 내부 로그에서만bad_password,locked,mfa_required를 구분한다. - 로그인 성공 직후에는 session id 재발급과 보안 이벤트 기록이 함께 일어나야 한다.
방어 설계
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"message": "이메일 또는 비밀번호를 확인해 주세요"
}서버 내부 로그는 더 자세해도 된다. 단, 원문 credential은 남기지 않는다.
로그인 흐름의 판단 기준은 다음과 같다.
- identifier 형식 오류와 credential 오류를 외부 메시지로 구분하지 않는다.
- 존재하지 않는 계정에도 비슷한 비용의 검증 경로를 태워 응답 시간 차이를 줄인다.
- 계정 상태는 내부 reason으로 기록하되 사용자에게 과도하게 노출하지 않는다.
- 실패 제한은 계정, IP, device, ASN, credential pair 단위를 함께 본다.
- 성공 후 세션 발급 전에 MFA 필요 여부를 판단한다.
Spring/HTTP 예시
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest servletRequest) {
LoginResult result = loginService.login(
request.identifier(),
request.password(),
ClientSignal.from(servletRequest));
if (result.requiresMfa()) {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(LoginResponse.mfaRequired(result.challengeId()));
}
return ResponseEntity.ok(LoginResponse.success());
}public LoginResult login(String identifier, String rawPassword, ClientSignal signal) {
NormalizedIdentifier normalized = identifierNormalizer.normalize(identifier);
AbuseDecision abuse = loginAbuseGuard.check(normalized, signal);
if (abuse.blocked()) {
audit.loginFailed(normalized.safeHash(), signal, "abuse_blocked");
throw new BadCredentialsException("bad credentials");
}
Account account = accountRepository.findByLoginIdentifier(normalized.value())
.orElseGet(unknownAccountProvider::dummyAccount);
boolean passwordMatches = passwordEncoder.matches(rawPassword, account.passwordHash());
if (!passwordMatches || !account.canLogin()) {
audit.loginFailed(normalized.safeHash(), signal, account.safeFailureReason());
throw new BadCredentialsException("bad credentials");
}
return loginSessionIssuer.issueAfterRiskCheck(account, signal);
}dummy account는 존재하지 않는 계정에서도 비슷한 password verification 비용을 내기 위한 방어다. 이것만으로 완벽한 timing 방어가 되는 것은 아니지만 계정 열거 신호를 줄인다.
운영 로그와 감사 지점
level=WARN event=login_failed result=denied identifier_hash=2f1c account_id=unknown reason=bad_credentials ip=203.0.113.10 user_agent_hash=81d2 risk_score=42 trace_id=aa140c91level=INFO event=login_succeeded result=allowed account_id=1004 auth_method=password mfa_required=true session_issued=false ip=203.0.113.10 trace_id=aa140c92로그에는 외부 응답보다 많은 내부 reason이 들어갈 수 있다. 그러나 원문 비밀번호, 전체 이메일, password hash, session id 원문은 남기지 않는다.
실전 판단 기준
- 존재하지 않는 계정과 비밀번호 오류 계정의 외부 응답이 같은가?
- 성공률만 보지 말고 실패 후 성공, 새 기기 로그인, MFA 변경 같은 후속 이벤트를 연결한다.
- credential 검증 실패가 비밀번호 원문이나 hash를 로그에 남기지 않는지 확인한다.
테스트 포인트
- 잠긴 계정, 탈퇴 계정, MFA 필요 계정이 공격자에게 세부 상태를 노출하지 않는가?
- 원문 비밀번호가 request log, exception log, APM attribute에 남지 않는가?
- credential stuffing 시 계정별, IP별, device별 제한이 모두 동작하는가?
- 성공 로그인 후 MFA 필요 상태에서는 완전한 세션 권한이 부여되지 않는가?
- 비밀번호 해시 work factor 변경 후 기존 계정 검증과 재해시 전략이 정의되어 있는가?
위험 신호!
- 로그인 실패 응답이
USER_NOT_FOUND,PASSWORD_MISMATCH,ACCOUNT_LOCKED를 그대로 노출한다. - 실패 횟수와 잠금 해제 시간을 공격자에게 자세히 알려 준다.
- 원문 password를 debug log나 authentication exception에 남긴다.
- rate limit이 IP 하나만 기준이라 분산 공격을 놓친다.
- MFA 필요 상태에서도 일반 로그인 세션과 같은 권한을 준다.
확인 질문
확인 질문
- 로그인 실패 메시지를 단순하게 만드는 이유는 무엇인가?
- 계정 존재 여부와 계정 상태를 공격자에게 알려 주지 않기 위해서다.
- credential 검증 로그에 남겨야 할 것과 남기면 안 되는 것은 무엇인가?
- identifier hash, result, reason, IP, user agent hash는 남기되 원문 비밀번호, password hash, session id 원문은 남기지 않는다.
- 로그인 성공 직후에도 추가 판단이 필요한 이유는 무엇인가?
- 새 기기, 새 지역, 높은 위험 점수, MFA 필요 상태에서는 세션 발급이나 권한 범위를 제한해야 하기 때문이다.