Skip to main content

Multi-Factor Authentication in Spring Security 7 with @EnableMultiFactorAuthentication

Password plus one-time token on Spring Boot 4.1.1 and Spring Security 7.1.1: FactorGrantedAuthority, @EnableMultiFactorAuthentication, step-up authentication for /admin with a freshness window, the real redirect URLs for a missing or expired factor, and the trap where a hand-built step-up factory silently drops the first factor. Ten runnable Java files.

For most of Spring Security’s history, “logged in” was a yes-or-no state. Multi-factor authentication needs a richer answer: how did this person prove who they are, and how recently? Spring Security 7 models that answer as ordinary authorities — FACTOR_PASSWORD, FACTOR_OTT — each with an issue time, plus an annotation that says “this application needs both”. This post builds two setups on Spring Boot 4.1: every page needs password plus a one-time token, and only /admin needs the token, and a fresh one (step-up authentication). It also records a trap I hit while writing it: the second setup, written exactly as the API suggests, quietly threw away the password factor after the token login and sent the user round in circles.
Versions tested. Spring Boot 4.1.1 (GA September 2026), Spring Security 7.1.1, JDK 25.0.4.1 LTS (Temurin). The MFA API names below — EnableMultiFactorAuthentication, AuthorizationManagerFactories.multiFactor(), RequiredFactor, FactorGrantedAuthority — were read from the 7.1.1 jars with javap before any code was written, then confirmed by compiling and running.

What is real and what is simulated. The filter chain, the one-time-token generation and login filters, and the authorization decisions are all the real Spring Security 7.1.1 code, driven through MockMvc. What is not exercised: actually delivering the token by email or SMS (the demo captures it in memory), and a browser. The “token issued 10 minutes ago” case builds the authentication by hand with a back-dated FactorGrantedAuthority rather than waiting ten minutes.

A factor is just an authority with a timestamp

When a user logs in with a password, Spring Security puts a FACTOR_PASSWORD authority on their Authentication. When they later log in with a one-time token, a FACTOR_OTT authority is added. Your normal roles (ROLE_USER, ROLE_ADMIN) sit alongside them. Authorization then becomes a question you already know how to ask — “does this authentication carry authority X?” — with the new twist that each factor authority also records when it was issued, so a rule can say “a token from the last five minutes”.
What the session carries at each step (captured from a real run)1. anonymous(no authorities)/api/profile: 302 /login2. password loginFACTOR_PASSWORDROLE_USER ROLE_ADMIN/api/profile: 302 (ott missing)3. + one-time tokenFACTOR_PASSWORDFACTOR_OTT + roles/api/profile: 200The page is protected by “has every required factor”, not by “is logged in”. Step 2 is logged in — and still blocked.
The diagram is the whole idea in one picture: being logged in and being allowed in are no longer the same thing. The numbers behind it are real — they come from MfaGlobalFlowTest.java.

Everything needs both factors: one annotation

The simplest setup is a single annotation naming the factors every protected request must carry. Put it on any configuration class, next to your normal filter chain, from MfaGlobalConfig.java:
@Configuration
@EnableWebSecurity
@Profile("global")
@EnableMultiFactorAuthentication(authorities = {
        FactorGrantedAuthority.PASSWORD_AUTHORITY,
        FactorGrantedAuthority.OTT_AUTHORITY })
public class MfaGlobalConfig {

    @Bean
    SecurityFilterChain chain(HttpSecurity http, MfaTokenCatcher catcher) throws Exception {
        return http
            .authorizeHttpRequests(a -> a
                .requestMatchers("/").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .oneTimeTokenLogin(o -> o.tokenGenerationSuccessHandler(catcher))
            .build();
    }
}
Notice that the rules themselves did not change: anyRequest().authenticated() is untouched. The annotation changes what authenticated() means for the whole application. Now follow one user through it (passwordAloneIsNotEnough in MfaGlobalFlowTest.java):
1  anonymous GET /api/profile        : 302 -> /login
2  POST /login (alice / password)    : 302 -> /
   authorities after password login  : [FACTOR_PASSWORD, ROLE_ADMIN, ROLE_USER]
3  GET /api/profile, password only   : 302 -> /login?factor.type=ott&factor.reason=missing
4  POST /ott/generate                : 302 -> /login/ott
   token issued (length)             : 36
5  POST /login/ott (the token)       : 302 -> http://localhost/api/profile?continue
   authorities after token login     : [FACTOR_OTT, FACTOR_PASSWORD, ROLE_ADMIN, ROLE_USER]
6  GET /api/profile, both factors    : 200 profile of alice
7  replaying the same token          : 302 -> /login?error
Read it top to bottom. Step 2 succeeds and the user lands on /, but step 3 shows the point of the feature: a perfectly valid password login is still redirected, and the redirect says why. Step 5 returns the user to the page they originally wanted (the ?continue marker is Spring’s saved-request mechanism), and the authorities after it show both factors — the second login added to the first instead of replacing it. Hold on to that detail; it is the subject of the trap below. Step 7 shows the token is single-use.

Reading the redirect: factor.type and factor.reason

The redirect from step 3 is worth decoding, because it is how a front end or a test can tell which factor is the problem: /login?factor.type=ott&factor.reason=missing. The type names the factor that is lacking and the reason says why. Across the runs in this post I observed two reasons: missing (the authority is absent) and expired (present but older than its allowed age, shown in the step-up section below). The type values were ott and password.

Delivering the token

Spring generates the token; you decide how it reaches the user. That is the tokenGenerationSuccessHandler: in production it sends an email or an SMS, then redirects to the page where the token is typed in. The demo’s handler stores the value so a test can read it (MfaTokenCatcher.java):
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken token) throws IOException {
    this.last = token.getTokenValue();
    response.sendRedirect("/login/ott");
}
Do not log or echo the token. The token is the second factor. A handler that writes it to the log, or returns it in the HTTP response “for testing”, turns the second factor into something anyone with log access can read. The catcher above exists only so the test can complete the flow; it must not ship.

Step-up: only /admin needs the second factor — and a fresh one

Asking every user for a token on every page is rarely what you want. The common pattern is step-up: log in with a password for everyday pages, and be asked for the second factor only when touching something sensitive. You build an authorization manager factory that demands the factors, and attach it to just the rules that need it, from MfaStepUpConfig.java:
AuthorizationManagerFactory<RequestAuthorizationContext> adminFactors =
    AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
        .requireFactor(f -> f.passwordAuthority())
        .requireFactor(f -> f.ottAuthority().validDuration(Duration.ofMinutes(5)))
        .build();

return http
    .authorizeHttpRequests(a -> a
        .requestMatchers("/").permitAll()
        .requestMatchers("/admin/**").access(adminFactors.hasRole("ADMIN"))
        .anyRequest().authenticated())
adminFactors.hasRole("ADMIN") reads naturally: the user must have the ADMIN role and both factors, and the token must be no older than five minutes. Everything else stays at plain authenticated(). Here is the real flow and the freshness cases (realStepUpFlow and factorMustBeFresh in MfaStepUpTest.java):
1  after password login, GET /api/profile : 200
2  GET /admin/panel (password only)       : 302 -> /login?factor.type=ott&factor.reason=missing
3  token submitted                        : 302 -> http://localhost/admin/panel?continue
   authorities after token login          : [FACTOR_OTT, FACTOR_PASSWORD, ROLE_ADMIN, ROLE_USER]
4  GET /admin/panel (password + token)    : 200 admin panel for alice [FACTOR_OTT, FACTOR_PASSWORD, ROLE_ADMIN, ROLE_USER]
token issued 1 minute ago   : 200
token issued 10 minutes ago : 302 -> /login?factor.type=ott&factor.reason=expired
no token at all             : 302 -> /login?factor.type=ott&factor.reason=missing
The normal page stays open after a password login (line 1); /admin sends the user to supply a token (lines 2–3); and the freshness window works as advertised — a token that is too old produces reason=expired, which is how you force a user to re-confirm before a sensitive action even though they are still logged in.

The trap: the second login must add to the first

The step-up code above works — but only because of a detail that is not in the code you wrote. The first version of the demo had exactly the factory above and nothing more. Its result:
after password login         : [FACTOR_PASSWORD, ROLE_ADMIN, ROLE_USER]
after valid token login      : [FACTOR_OTT, ROLE_ADMIN, ROLE_USER]
GET /admin/panel             : 302 -> /login?factor.type=password&factor.reason=missing
That is from secondLoginReplacesTheFirstFactor in MfaStepUpBareTest.java. Look at the second line: after a valid token login, FACTOR_PASSWORD is gone. The third line is the user-visible symptom: the user, who just proved both factors, is told the password is missing. They would log in with the password, be sent for a token, and arrive at the same screen — forever.
The fingerprint: a redirect to factor.type=password&factor.reason=missing immediately after a successful second-factor login. It means the factors are being replaced, not accumulated.

The cause, from reading the 7.1.1 bytecode: @EnableMultiFactorAuthentication imports a configuration whose post-processor calls setMfaEnabled(true) on the login filters (AbstractAuthenticationProcessingFilter, AuthenticationFilter, AbstractPreAuthenticatedProcessingFilter and BasicAuthenticationFilter). That switch is what makes a second login merge with the factors already on the session. Building the authorization factory by hand does not flip it — the annotation does. I read this with javap; I did not read the filter source, so treat “what exactly the switch does” as inferred from the observed behaviour.

The fix: keep the hand-built factory and also add the annotation with an empty factor list, so nothing is required globally but the carry-forward behaviour is on:
@Configuration
@Profile("stepup")
@EnableMultiFactorAuthentication(authorities = {})
static class FactorCarryForward {
}
That nested class is in MfaStepUpConfig.java; the profile switch (stepup versus stepupbare) is only there so one project can demonstrate both the failure and the fix. With it in place you get the working output shown in the step-up section.
Going deeper: the rest of the MFA API surface (read from the jars, not all executed) These are the shapes of the 7.1.1 API as printed by javap. I used the ones marked “used” in the demos; the rest I only inspected, so check them against the reference documentation before relying on them.
  • FactorGrantedAuthority constants (inspected): PASSWORD_AUTHORITY (used), OTT_AUTHORITY (used), WEBAUTHN_AUTHORITY, AUTHORIZATION_CODE_AUTHORITY, BEARER_AUTHORITY, CAS_AUTHORITY, SAML_RESPONSE_AUTHORITY, X509_AUTHORITY. Build one with FactorGrantedAuthority.withAuthority(...).issuedAt(Instant).build() (used, in the freshness test).
  • RequiredFactor.Builder: passwordAuthority(), ottAuthority() (used), webauthnAuthority(), samlAuthority(), x509Authority() and others, plus validDuration(Duration) (used).
  • AuthorizationManagerFactories.multiFactor() returns a builder with requireFactor(...) (used), requireFactors(String...), and when(Predicate<Authentication>) to apply the extra factors only to some users.
  • The annotation also has a when attribute of type MultiFactorCondition[], and the selector references a class named WhenWebAuthnRegisteredMfaConfiguration. From the names, this looks like “require the second factor only for users who have registered a passkey”; I have not run it.
Compile and run: put the main files in src/main/java/demo and the tests in src/test/java/demo of a project whose pom.xml uses spring-boot-starter-parent 4.1.1 with spring-boot-starter-webmvc, spring-boot-starter-security and (test scope) spring-boot-starter-webmvc-test and spring-boot-starter-security-test, then mvn test. The tests write the transcripts quoted here to a cap/ folder.

Should you build MFA this way?

An emailed code is the weakest respectable second factor. If an attacker controls the user’s mailbox — which is often how the password was reset or phished in the first place — both factors fall together. It is fine as a basic step-up and a good way to learn the machinery, but for anything valuable prefer a phishing-resistant factor. The factor model above is the same for passkeys: the post on passkeys and WebAuthn in Spring Security 7 adds FACTOR_WEBAUTHN in place of FACTOR_OTT. And if your login is delegated to an identity provider, MFA is usually that provider’s job — see running your own OIDC provider before building it into every application.

Downloads

Ten files, all package demo. Main sources: Tests (they produce the output blocks above):

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.