인증 서버에 2단계 인증을 붙이고 나서, 패스워드로 로그인하고 일회용 토큰까지 넣었는데 보호된 자원이 계속 막혔습니다. 두 인증 다 성공 응답이 왔는데도 그랬습니다.
권한을 찍어보니 이유가 보였습니다. 2차 인증에 성공하는 순간 1차 인증의 표시가 사라져 있었습니다.
Spring Security MFA 는 인증 수단마다 표시를 붙이고 인가 시점에 조합을 확인하는 구조인데, 그 표시가 쌓이지 않고 있었습니다.

Spring Boot 4.0.0 / Spring Security 7 / JDK 25 기준이다.
Spring Security MFA 는 권한으로 표현된다
Spring Security 7 의 다단계 인증은 별도 상태 기계가 아니다. “이 방식으로 인증했다”는 표시를 권한으로 붙이고, 인가 시점에 필요한 조합을 요구하는 구조다.
표시는 FactorGrantedAuthority 다. 상수가 이렇게 들어 있다.
public static final String PASSWORD_AUTHORITY = "FACTOR_PASSWORD";
public static final String OTT_AUTHORITY = "FACTOR_OTT";
public static final String WEBAUTHN_AUTHORITY = "FACTOR_WEBAUTHN";
public static final String X509_AUTHORITY = "FACTOR_X509";
요구하는 쪽은 AuthorizationManagerFactories 다.
AuthorizationManager<RequestAuthorizationContext> bothFactors =
AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
.requireFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.OTT_AUTHORITY)
.build()
.authenticated();
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/secure").access(bothFactors)
.anyRequest().authenticated());
여기까지는 문서대로다. 문제는 그 권한이 어떻게 쌓이느냐였다.
테스트 1 — 기본 상태로 두 번 인증한다
사용자 하나짜리 최소 프로젝트를 만들었다. 폼 로그인과 일회용 토큰 로그인을 둘 다 켜고, 매 단계마다 권한을 찍는다.
### demo.merge-factors=false
--- 1. 패스워드 로그인 ---
로그인 HTTP 302
name=hoony via=UsernamePasswordAuthenticationToken authorities=[FACTOR_PASSWORD, ROLE_USER]
--- 2. 두 factor 가 필요한 자원 ---
[HTTP 302]
--- 3. 일회용 토큰으로 2차 인증 ---
OTT OK hoony [FACTOR_OTT, ROLE_USER]
name=hoony via=OneTimeTokenAuthentication authorities=[FACTOR_OTT, ROLE_USER]
--- 4. 다시 그 자원 ---
[HTTP 302]
3번을 보면 FACTOR_PASSWORD 가 없다. 2차 인증이 성공하면서 SecurityContext 의 Authentication 을 통째로 새 객체로 갈아치웠고, 새 객체에는 방금 붙인 FACTOR_OTT 만 들어 있다.
그래서 4번이 여전히 302다. 사용자 입장에서는 두 번 다 성공했는데 아무것도 안 열린다.
왜 덮어쓰는가
AbstractAuthenticationProcessingFilter 를 열어보면 병합이 조건부다.
Authentication current = this.securityContextHolderStrategy.getContext().getAuthentication();
if (shouldPerformMfa(current, authenticationResult)) {
authenticationResult = authenticationResult.toBuilder()
.authorities((a) -> {
Set<String> newAuthorities = a.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toUnmodifiableSet());
for (GrantedAuthority currentAuthority : current.getAuthorities()) {
if (!newAuthorities.contains(currentAuthority.getAuthority())) {
a.add(currentAuthority);
}
}
})
.build();
}
shouldPerformMfa 가 false 면 병합 없이 그냥 교체된다. 그 판정이 이렇게 생겼다.
private boolean shouldPerformMfa(@Nullable Authentication current, Authentication authenticationResult) {
if (!this.mfaEnabled) {
return false;
}
if (current == null || !current.isAuthenticated()) {
return false;
}
if (!declaresToBuilder(authenticationResult)) {
return false;
}
return current.getName().equals(authenticationResult.getName());
}
조건이 네 개다. mfaEnabled 의 기본값이 false 라서 아무 설정도 안 하면 항상 교체다.
나머지 셋도 그냥 넘길 게 아니다. 세 번째 조건은 특히 조용하다.
private static boolean declaresToBuilder(Authentication authentication) {
for (Method method : authentication.getClass().getDeclaredMethods()) {
if (method.getName().equals("toBuilder") && method.getParameterTypes().length == 0) {
return true;
}
}
return false;
}
getDeclaredMethods() 라서 상속받은 toBuilder() 는 쳐주지 않는다. 커스텀 Authentication 을 만들면서 부모 것을 그대로 쓰면, 설정을 다 켜놨는데도 병합이 안 된다. 예외도 로그도 없다.
테스트 2 — 병합을 켠다
@EnableMultiFactorAuthentication 이 이걸 전역으로 켜준다. 다만 필터 체인이 여럿이면 전역 적용이 곤란해서, 어노테이션이 하는 일을 필요한 체인에만 손으로 배선했다.
.oneTimeTokenLogin(ott -> ott
.withObjectPostProcessor(new ObjectPostProcessor<OneTimeTokenAuthenticationFilter>() {
@Override
public <O extends OneTimeTokenAuthenticationFilter> O postProcess(O filter) {
filter.setMfaEnabled(true);
return filter;
}
})
...
같은 시나리오를 다시 돌린 결과다.
### demo.merge-factors=true
--- 1. 패스워드 로그인 ---
name=hoony via=UsernamePasswordAuthenticationToken authorities=[FACTOR_PASSWORD, ROLE_USER]
--- 3. 일회용 토큰으로 2차 인증 ---
OTT OK hoony [FACTOR_OTT, FACTOR_PASSWORD, ROLE_USER]
name=hoony via=OneTimeTokenAuthentication authorities=[FACTOR_OTT, FACTOR_PASSWORD, ROLE_USER]
--- 4. 다시 그 자원 ---
SECURE OK [FACTOR_OTT, FACTOR_PASSWORD, ROLE_USER] [HTTP 200]
이제 쌓인다. 두 factor 를 다 갖췄으니 자원도 열린다.
바뀐 건 설정 한 줄이고, 나머지 코드는 두 번 다 같다.
이 구조가 주는 것
권한으로 표현하니까 인증 수단을 더 붙여도 인가 규칙을 안 건드려도 된다. 패스키를 추가하면 FACTOR_WEBAUTHN 이 붙고, 그걸 단독 강인증으로 인정할지는 요구 조합만 바꾸면 된다.
게이트가 인정하는 조합
WEBAUTHN 단독
PASSWORD + OTT
PASSWORD + TOTP
반대로 말하면, 표시가 안 쌓이면 이 설계 전체가 무너진다. 2차 인증까지 통과한 사용자가 계속 문 앞에서 막히는데 에러는 안 나는 상태가 된다.
인증 수단을 붙일 때 “로그인이 되나”만 보고 넘어가기 쉽다. 그다음 화면까지 한 번은 통과시켜봐야 한다.
같은 인증 서버에서 인증 경로마다 계정 잠금이 다르게 걸리던 이야기는 Spring Security 계정 상태 검사 단일화 에 적어뒀다.