인증 서버에 패스키(WebAuthn)와 일회용 토큰 로그인을 붙이고 QA 를 하다가, 잠긴 계정이 로그인되는 걸 발견했습니다. 패스워드로는 분명히 막히는 계정이었습니다.
원인은 Spring Security 계정 상태 검사를 누가 하느냐가 인증 경로마다 다르다는 것이었습니다. 아래는 그걸 확인하고 고친 기록입니다. 회사 코드를 그대로 옮길 수는 없어서 최소 프로젝트로 다시 재현했고, 붙인 로그는 전부 그걸 돌려서 나온 것입니다.
Spring Boot 4.0.0 / Spring Security 7.0.6 / JDK 25 기준이다.

Spring Security 계정 상태 검사는 Provider 안에 있다
UserDetails 에는 계정 상태 플래그가 네 개 있다.
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
문제는 이걸 인터페이스가 스스로 강제하지 않는다는 것이다. 누군가 읽어서 판단해줘야 한다. 패스워드 로그인에서는 DaoAuthenticationProvider 가 그 역할을 한다.
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
부모인 AbstractUserDetailsAuthenticationProvider 안에 검사기가 들어 있다.
private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
@Override
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) { ... }
if (!user.isEnabled()) { ... }
if (!user.isAccountNonExpired()) { ... }
}
}
즉 잠금 검사는 UserDetails 의 성질이 아니라 DaoAuthenticationProvider 계열의 기능이다. 여기까지 알고 나면 다음 코드가 무슨 뜻인지 보인다.
다른 Provider 들은 이 검사를 하지 않는다
OneTimeTokenAuthenticationProvider 전문이다. 주석만 걷어냈다.
public final class OneTimeTokenAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OneTimeTokenAuthenticationToken otpAuthenticationToken = (OneTimeTokenAuthenticationToken) authentication;
OneTimeToken consumed = this.oneTimeTokenService.consume(otpAuthenticationToken);
if (consumed == null) {
throw new InvalidOneTimeTokenException("Invalid token");
}
try {
UserDetails user = this.userDetailsService.loadUserByUsername(consumed.getUsername());
Collection<GrantedAuthority> authorities = new HashSet<>(user.getAuthorities());
authorities.add(FactorGrantedAuthority.fromAuthority(AUTHORITY));
OneTimeTokenAuthentication authenticated = new OneTimeTokenAuthentication(user, authorities);
authenticated.setDetails(otpAuthenticationToken.getDetails());
return authenticated;
}
catch (UsernameNotFoundException ex) {
throw new BadCredentialsException("Failed to authenticate the one-time token");
}
}
AbstractUserDetailsAuthenticationProvider 를 상속하지 않고 AuthenticationProvider 를 직접 구현한다. 토큰을 소비하고, 사용자를 불러오고, 권한을 붙여서 인증 객체를 만든다. 그 사이에 상태 검사는 없다.
WebAuthnAuthenticationProvider 도 같은 모양이다.
public class WebAuthnAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
WebAuthnAuthenticationRequestToken webAuthnRequest = (WebAuthnAuthenticationRequestToken) authentication;
try {
PublicKeyCredentialUserEntity userEntity = this.relyingPartyOperations
.authenticate(webAuthnRequest.getWebAuthnRequest());
String username = userEntity.getName();
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
Collection<GrantedAuthority> authorities = new HashSet<>(userDetails.getAuthorities());
authorities.add(FactorGrantedAuthority.fromAuthority(AUTHORITY));
return new WebAuthnAuthentication(userEntity, authorities);
}
catch (RuntimeException ex) {
throw new BadCredentialsException(ex.getMessage(), ex);
}
}
서명 검증은 relyingPartyOperations.authenticate 가 제대로 한다. 서명이 맞으면 그 다음은 사용자를 불러와 권한을 붙일 뿐이다. 계정이 잠겼는지는 보지 않는다.
7.0.6 과 7.1.0 의 두 파일을 받아서 비교해봤는데 차이가 없었다. 최신 버전에서도 같다.
테스트 1 — 패스워드로 로그인
잠긴 계정 하나만 들고 있는 최소 프로젝트를 만들었다. accountLocked(true) 로 만든 locked 사용자 하나가 전부다.
User.withUsername("locked").password(encoded).authorities("ROLE_USER").accountLocked(true).build()
폼 로그인과 일회용 토큰 로그인을 둘 다 켜두고, 실패 핸들러에서 예외 종류를 그대로 뱉게 했다.
--- 1. 패스워드 로그인 (잠긴 계정) ---
LOGIN FAIL: LockedException / User account is locked [HTTP 401]
막힌다. DaoAuthenticationProvider 가 검사하기 때문이다.
테스트 2 — 같은 계정을 일회용 토큰으로
토큰을 발급받아 그대로 로그인해본다.
--- 2. 일회용 토큰 발급 ---
token=8878ce20-c541-482d-b652-c761a776aee6
--- 3. 그 토큰으로 로그인 ---
OTT OK: locked [FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=2026-08-19T14:01:42.730520750Z], ROLE_USER] [HTTP 200]
--- 4. 세션 확인 ---
authenticated=locked authorities=[FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=2026-08-19T14:01:42.730520750Z], ROLE_USER] via=OneTimeTokenAuthentication [HTTP 200]
들어갔다. 세션까지 만들어져서 인증이 필요한 엔드포인트가 200 으로 응답한다. 같은 계정, 같은 애플리케이션인데 경로만 바꿨더니 통과한 것이다.
패스키도 원인이 같으므로 결과도 같다. 다만 패스키는 브라우저와 인증기가 있어야 재현되니 여기서는 OTT 로 확인했다.
토큰 발급 단계도 짚고 갈 만하다. GenerateOneTimeTokenFilter 는 사용자를 조회하지 않는다.
OneTimeToken ott = this.tokenService.generate(generateRequest);
this.tokenGenerationSuccessHandler.handle(request, response, ott);
요청에서 username 을 꺼내 토큰을 만들고 핸들러에 넘길 뿐이다. 그래서 잠긴 계정으로도 토큰은 정상 발급된다. 막을 곳은 발급이 아니라 소비 쪽이다.
테스트 3 — 검사를 UserDetailsService 로 옮긴다
Provider 마다 검사를 하나씩 붙이는 방법도 있다. 그렇게 하지 않았다. 인증 수단을 하나 더 붙일 때마다 똑같은 걸 또 붙여야 하고, 한 번 빠뜨리면 이번과 같은 일이 다시 생긴다.
대신 모든 경로가 반드시 지나는 지점에 뒀다. 위의 세 Provider 는 구현이 제각각이지만 userDetailsService.loadUserByUsername(...) 을 호출한다는 점은 같다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = this.users.get(username);
if (user == null) {
throw new UsernameNotFoundException(username);
}
if (this.checkOnLoad) {
new AccountStatusUserDetailsChecker().check(user);
}
return user;
}
AccountStatusUserDetailsChecker 는 Spring Security 가 제공하는 구현체다.
잠금·비활성·만료를 각각 LockedException, DisabledException, AccountExpiredException 으로 구분해 던진다.
응답 코드나 메시지를 직접 정해야 하면 UserDetailsChecker 를 구현하면 된다. 실제 서비스에서는 이렇게 썼다.
@Slf4j
@Component
public class AccountStatusChecker implements UserDetailsChecker {
@Override
public void check(@NonNull UserDetails user) {
if (!user.isAccountNonLocked()) {
log.info("User account is locked: {}", user.getUsername());
throw new AuthException(AuthCode.LOCKED_USER);
}
if (!user.isEnabled()) {
log.info("User account is disabled: {}", user.getUsername());
throw new AuthException(AuthCode.DISABLED_USER);
}
if (!user.isAccountNonExpired()) {
log.info("User account is expired: {}", user.getUsername());
throw new AuthException(AuthCode.EXPIRED_USER);
}
if (!user.isCredentialsNonExpired()) {
log.info("User credentials have expired: {}", user.getUsername());
throw new AuthException(AuthCode.EXPIRED_CREDENTIALS);
}
}
}
AuthException 은 응답 코드와 메시지를 들고 있는 우리 예외다. 이걸 UserDetailsService 에 주입해서 부른다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userModuleService.getUser(username)
.orElseThrow(() -> new AuthException(AuthCode.NOT_FOUND_USER));
CustomUserDetails userDetails = new CustomUserDetails(user);
// 계정 상태(잠금/만료/비활성) 검증을 로드 시점에 일괄 적용한다.
// 패스워드뿐 아니라 OTT·패스키 등 모든 인증 경로가 이 메서드를 거치므로, 잠긴 계정의 우회를 차단한다.
accountStatusChecker.check(userDetails);
return userDetails;
}
원래는 이 check() 호출이 AuthenticationProvider 세 곳에 흩어져 있었다. 그 세 곳을 지우고 여기 한 번만 두는 게 수정의 전부였다.
같은 시나리오를 다시 돌린 결과다.
--- 1. 패스워드 로그인 (잠긴 계정) ---
LOGIN FAIL: InternalAuthenticationServiceException / User account is locked [HTTP 401]
--- 3. 그 토큰으로 로그인 ---
OTT FAIL: LockedException / User account is locked [HTTP 401]
--- 4. 세션 확인 ---
[HTTP 302]
둘 다 막힌다. 세션도 만들어지지 않아서 인증이 필요한 요청은 로그인 페이지로 돌아간다.
옮기고 나면 예외 타입이 바뀐다
위 로그에서 하나 달라진 게 있다. 패스워드 경로의 예외가 LockedException 에서 InternalAuthenticationServiceException 으로 바뀌었다.
DaoAuthenticationProvider.retrieveUser 때문이다.
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
...
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
UsernameNotFoundException 이 아닌 예외는 전부 InternalAuthenticationServiceException 으로 감싼다.
잠금 검사를 loadUserByUsername 안으로 옮기는 순간, 패스워드 경로의 예외는 이 catch 에 걸린다.
instanceof 로 예외를 보고 에러 코드를 붙이고 있었다면 여기서 조용히 어긋난다. 잠금은 제대로 막히는데 화면에는 “일시적인 오류”가 뜨는 식이다. 예외를 분류하는 곳에서 getCause() 까지 봐야 한다.
WebAuthnAuthenticationProvider 는 더하다. 위에 인용한 대로 catch (RuntimeException ex) 로 전부 잡아서 BadCredentialsException(ex.getMessage(), ex) 로 감싼다.
loadUserByUsername 이 던진 예외가 직접 예외가 아니라 cause 로 들어가 있다. 그래서 실패 핸들러에서 그냥 instanceof 를 쓰면 잠금 사유를 못 읽는다.
실패 핸들러를 붙였는데도 잠금 메시지가 안 떠서 한참 봤던 부분이다. cause 쪽도 같이 뒤지도록 고쳤다.
정리
UserDetails 의 계정 상태 플래그는 선언만으로 동작하지 않는다. 읽어주는 Provider 가 있어야 하고, DaoAuthenticationProvider 계열만 그 일을 한다.
일회용 토큰이나 패스키처럼 AuthenticationProvider 를 직접 구현한 인증 수단은 검사를 하지 않는다.
인증 수단을 새로 붙일 때 서명 검증이나 토큰 검증은 프레임워크가 해주니 신경 쓰게 되는데, 계정 상태는 그 바깥에 있어서 눈에 잘 안 띈다. 수단을 추가하면 검사도 같이 따라오는지 한 번 확인해볼 만하다.
흩어진 걸 한 곳으로 모으는 이야기는 이 서버에서 한 번 더 있었다. 로그 필터가 2단계 인증 시크릿을 통째로 남기고 있던 건은 응답 바디 로깅에 TOTP 시크릿이 그대로 남았다 에 적어뒀다.