Adaptive MFA
Multi-Factor Authentication (MFA) configuration guide. Combines secondary factors (OTT, Passkey) after primary authentication (Form/REST) to strengthen security. Supports AI-based adaptive policies, custom pages, and Step-up authentication.
Overview
Contexa MFA consists of primary authentication + secondary factor(s). The policy engine determines whether MFA is required, and the state machine manages the authentication flow.
The default MFA order is 200. Calling .form() or .rest() directly from .mfa() throws an UnsupportedOperationException. You must use .primaryAuthentication() instead.
Quick Start
The simplest MFA configuration with Form primary authentication + Passkey secondary authentication.
registry
.mfa(mfa -> mfa
.primaryAuthentication(primary -> primary
.formLogin(form -> form.loginPage("/login")))
.passkey(Customizer.withDefaults())
)
.session(Customizer.withDefaults())
.build();
Primary Authentication
Configures the primary authentication method via primaryAuthentication(). Only one of formLogin() or restLogin() can be selected (mutually exclusive).
Form Primary
.mfa(mfa -> mfa
.primaryAuthentication(primary -> primary
.formLogin(form -> form
.loginPage("/login")
.usernameParameter("email")
.failureUrl("/login?error")))
.ott(Customizer.withDefaults())
)
REST Primary
.mfa(mfa -> mfa
.primaryAuthentication(primary -> primary
.restLogin(rest -> rest
.loginProcessingUrl("/api/auth/login")))
.passkey(Customizer.withDefaults())
)
Primary authentication is automatically assigned order 0. Secondary factors are automatically assigned sequential order values (1, 2, 3...) via
currentStepOrderCounter.
MFA Factors (Secondary Authentication)
Adds secondary authentication factors. At least one secondary factor is required, and multiple factors can be combined.
Available Secondary Factors
| Method | Factor | Description |
|---|---|---|
.ott() | One-Time Token | Email/SMS one-time code |
.passkey() | WebAuthn / Passkey | Biometric authentication / hardware key |
Multiple Factor Combination
.mfa(mfa -> mfa
.primaryAuthentication(primary -> primary
.formLogin(form -> form.loginPage("/login")))
.ott(ott -> ott
.tokenService(emailTokenService))
.passkey(passkey -> passkey
.rpName("My App")
.rpId("example.com"))
)
Each factor is automatically assigned a sequential order via currentStepOrderCounter (1, 2, 3...). Users select their preferred factor on the factor selection page.
requiredFactors() - Required Factor Count
Controls how many secondary factors must be completed for MFA to succeed. By default, all registered factors must be completed.
// Register 2 factors, but only 1 is required to complete MFA
.mfa(mfa -> mfa
.primaryAuthentication(primary -> primary
.formLogin(Customizer.withDefaults()))
.passkey(Customizer.withDefaults())
.ott(Customizer.withDefaults())
.requiredFactors(1)
)
| Setting | Behavior |
|---|---|
.requiredFactors(1) | Only 1 factor needs to succeed. The first factor starts automatically; if it fails, "Try another method" link allows switching to a different factor. |
.requiredFactors(2) | 2 factors must succeed. After completing one, the next uncompleted factor starts automatically. |
| Not set (default) | All registered factors must be completed. |
When multiple factor types are registered, each MFA challenge page displays a "Try another method" link. Already completed factor types are excluded from the selection page. The same factor type cannot be used twice.
Use Case: Flexible Fallback
Register multiple factors for redundancy while requiring only one. Users who cannot use Passkey (e.g., no biometric hardware) can fall back to OTT email verification.
// Admin portal: Passkey preferred, OTT as fallback
.mfa(mfa -> mfa
.urlPrefix("/admin")
.primaryAuthentication(primary -> primary
.formLogin(form -> form.defaultSuccessUrl("/admin")))
.passkey(Customizer.withDefaults()) // Starts first (auto-selected)
.ott(Customizer.withDefaults()) // Available as fallback
.requiredFactors(1) // Only 1 needed
)
MFA Pages
Customizes the page URLs for each step of the MFA flow via mfaPage().
| Method | Description |
|---|---|
selectFactorPage(String) | Factor selection page URL |
ottPages(String requestUrl, String verifyUrl) | OTT request/verification page URLs (configured at once) |
ottRequestPage(String) | OTT code request page URL |
ottVerifyPage(String) | OTT code verification page URL |
passkeyChallengePages(String) | Passkey challenge page URL |
configurePageUrl(String) | MFA configuration page URL |
failurePageUrl(String) | MFA failure page URL |
.mfa(mfa -> mfa
.primaryAuthentication(primary -> primary
.formLogin(form -> form.loginPage("/login")))
.ott(Customizer.withDefaults())
.mfaPage(page -> page
.selectFactorPage("/mfa/select")
.ottPages("/mfa/ott/request", "/mfa/ott/verify")
.failurePageUrl("/mfa/failed"))
)
Creating Custom MFA Pages
You can freely modify the CSS and layout of MFA pages. However, the following elements must be preserved.
Do Not Modify
- CSRF meta tag:
<meta name="_csrf" th:content="${csrfToken}"> id="contexa-page-config"div anddata-*attributes- Input name attributes:
username,password,token,_csrf,mfaSessionId - Form
id,method,th:action - button id:
loginButton,sendCodeButton,verifyButton,resendButton,authButton th:utext="${hiddenInputsHtml}"??Auto-generates CSRF + mfaSessionId hidden inputs
Safe to Modify
- CSS colors, layout, background
- Logos, icons
- Text content
- Form field styles
Required JavaScript Files
| File | Target Page |
|---|---|
/js/contexa-mfa-sdk.js | All MFA pages (required) |
/js/contexa-login-page.js | Login page |
/js/contexa-ott-request-page.js | OTT code request page |
/js/contexa-ott-verify-page.js | OTT code verification page |
/js/contexa-passkey-page.js | Passkey challenge page |
MFA Data Flow
MFA Policy Customization
Implement MfaPolicyProvider to customize MFA requirement decisions. Since it is registered with @ConditionalOnMissingBean, defining a bean of the same type automatically overrides it.
Built-in Implementations
| Implementation | Description |
|---|---|
DefaultMfaPolicyProvider | Static rule-based. Determines MFA requirement based on user roles and MFA configuration |
AIAdaptiveMfaPolicyProvider | AI risk analysis-based dynamic policy. Falls back to DefaultMfaPolicyProvider when AI Core is unavailable |
Custom Policy Implementation Example
@Bean
public MfaPolicyProvider mfaPolicyProvider() {
return new MfaPolicyProvider() {
@Override
public MfaDecision evaluateInitialMfaRequirement(FactorContext ctx) {
String ip = ctx.getRequest().getRemoteAddr();
if (trustedIpRanges.contains(ip)) {
return MfaDecision.noMfaRequired();
}
return MfaDecision.challenged("Untrusted IP");
}
// ... other methods
};
}
MfaDecision Types
| DecisionType | Description |
|---|---|
NO_MFA_REQUIRED | MFA not required. Authentication completes immediately |
CHALLENGED | MFA required. Secondary factor authentication demanded |
BLOCKED | Authentication blocked. Access denied |
ESCALATED | Escalated for administrator review. Only administrators can resolve |
Injecting Policy via DSL
.mfa(mfa -> mfa
.policyProvider(customMfaPolicyProvider)
.primaryAuthentication(primary -> primary
.formLogin(form -> form.loginPage("/login")))
.passkey(Customizer.withDefaults())
)
Step-up Authentication
Requires additional authentication when an already authenticated user accesses sensitive resources.
The ContexaMFA SDK Global Interceptor automatically detects 401/403/423 responses from all HTTP requests and redirects to the MFA challenge. After authentication completes, the original request is automatically retried.
MFA State Machine Events
Use @EventListener to receive MFA state change events for implementing audit logs, notifications, and more.
Event Types
| Event | Description |
|---|---|
StateChangeEvent | MFA state transition (e.g., FACTOR_SELECTION -> OTT_CHALLENGE) |
ErrorEvent | Error occurred (SECURITY, TIMEOUT, LIMIT_EXCEEDED, SYSTEM) |
PerformanceAlertEvent | Performance warning (slow response, etc.) |
CustomEvent | User-defined event |
Terminal States
| State | Description |
|---|---|
MFA_SUCCESSFUL | MFA succeeded. Full authentication complete |
MFA_NOT_REQUIRED | MFA not required. Skipped by policy |
MFA_FAILED_TERMINAL | MFA permanently failed. No retry allowed |
MFA_CANCELLED | User cancelled MFA |
MFA_TIMEOUT | Session timeout |
MFA_SESSION_EXPIRED | MFA session expired |
MFA_ACCOUNT_LOCKED | Account locked |
MFA_SYSTEM_ERROR | System error |
Audit Log Implementation Example
@EventListener
public void onMfaStateChange(MfaStateMachineEvents.StateChangeEvent event) {
auditService.log(
event.getUserId(),
event.getFromState(),
event.getToState()
);
}
@EventListener
public void onMfaError(MfaStateMachineEvents.ErrorEvent event) {
alertService.notify(
event.getErrorType(),
event.getMessage()
);
}
Multi MFA
Run multiple independent MFA flows simultaneously. Each flow has its own SecurityFilterChain, secondary authentication factors, and URL namespace.
Each MFA flow is configured as an independent SecurityFilterChain. Setting
urlPrefix() automatically applies a prefix to all MFA URLs and configures securityMatcher, ensuring complete isolation between flows.
requiredFactors() — Multi-Factor Count
By default, MFA requires all configured secondary factors to complete. For example, if you configure both .ott() and .passkey(), the user must complete both before MFA succeeds. Use requiredFactors(int count) to override this and require fewer factors than configured.
Default behavior: AbstractMfaPolicyEvaluator.getBaseFactorCountFromConfig() counts non-primary steps in the flow configuration. If requiredFactors() is not called, the default is the total number of registered MFA factors (e.g., .ott() + .passkey() = 2).
// Default: both OTT and Passkey required (2 factors registered = 2 required)
registry
.mfa(mfa -> mfa
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.ott(Customizer.withDefaults())
.passkey(Customizer.withDefaults())
).session(Customizer.withDefaults())
// Override: require only 1 of the 2 configured factors
registry
.mfa(mfa -> mfa
.requiredFactors(1)
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.ott(Customizer.withDefaults())
.passkey(Customizer.withDefaults())
).session(Customizer.withDefaults())
| Value | Behavior | Example |
|---|---|---|
| Not set (default) | All configured factors must be completed. If OTT + Passkey are registered, the user must complete both. | OTT + Passkey configured → 2 required |
1 | User completes any one factor (OTT or Passkey), then MFA succeeds. | Standard 2FA: password + one additional factor |
N | User must complete exactly N distinct factors in sequence. | Custom policy: require N of M configured factors |
Important: requiredFactors(N) must not exceed the number of configured factors. The state machine's AllFactorsCompletedGuard checks completedCount >= requiredCount — if required exceeds available, MFA can never succeed.
Basic Configuration
Registering multiple .mfa() calls automatically assigns unique typeNames.
registry
.mfa(mfa -> mfa
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.passkey(Customizer.withDefaults())
.order(60)
).session(Customizer.withDefaults()) // typeName = "mfa" (auto)
.mfa(mfa -> mfa
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.ott(Customizer.withDefaults())
.order(70)
).session(Customizer.withDefaults()) // typeName = "mfa_2" (auto)
.build();
| Registration Order | Auto typeName | Description |
|---|---|---|
First .mfa() | mfa | Same as single MFA (backward compatible) |
Second .mfa() | mfa_2 | Auto-numbered |
Third .mfa() | mfa_3 | Auto-numbered |
name() - Explicit Flow Naming
Use name() to assign a meaningful flow name instead of auto-numbering.
.mfa(mfa -> mfa
.name("admin") // typeName = "mfa_admin"
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.passkey(Customizer.withDefaults())
.order(60)
).session(Customizer.withDefaults())
.mfa(mfa -> mfa
.name("user") // typeName = "mfa_user"
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.ott(Customizer.withDefaults())
.order(70)
).session(Customizer.withDefaults())
urlPrefix() - URL Isolation
urlPrefix() automatically applies a prefix to all MFA URLs for the flow and auto-configures securityMatcher. This ensures each Multi MFA flow operates in its own URL namespace.
registry
// Admin: Passkey MFA
.mfa(mfa -> mfa
.urlPrefix("/admin")
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.passkey(Customizer.withDefaults())
.order(60)
).session(Customizer.withDefaults())
// User: OTT MFA
.mfa(mfa -> mfa
.urlPrefix("/user")
.primaryAuthentication(auth -> auth.formLogin(Customizer.withDefaults()))
.ott(Customizer.withDefaults())
.order(70)
).session(Customizer.withDefaults())
.build();
URL transformation when urlPrefix("/admin") is set:
| Default URL | After urlPrefix |
|---|---|
/mfa/login | /admin/mfa/login |
/mfa/select-factor | /admin/mfa/select-factor |
/login/mfa-ott | /admin/login/mfa-ott |
/login/mfa-webauthn | /admin/login/mfa-webauthn |
/mfa/cancel | /admin/mfa/cancel |
/api/mfa/config | /admin/api/mfa/config |
Setting
urlPrefix("/admin") automatically applies http.securityMatcher("/admin/**"). No need to use rawHttp() separately.
defaultLoginUrl() - Individual URL Override
Use this when you want to change individual URLs instead of applying urlPrefix(). Changes the auto-generated login form URL while maintaining Spring Security standard custom page behavior.
| API | Behavior | Auto Form Generation |
|---|---|---|
.loginPage("/custom") | Redirects to custom page (Spring Security standard) | No (user-provided page) |
.defaultLoginUrl("/admin/mfa/login") | Changes auto-generated form URL | Yes (Contexa auto-generated) |
.mfa(mfa -> mfa
.primaryAuthentication(auth -> auth.formLogin(form -> form
.defaultLoginUrl("/admin/mfa/login")
.defaultSuccessUrl("/admin/dashboard")))
.passkey(Customizer.withDefaults())
.order(60)
).session(Customizer.withDefaults())
Multi MFA DSL API Summary
| Method | Location | Description |
|---|---|---|
.name(String) | .mfa() | Set flow name. typeName = mfa_{name} |
.urlPrefix(String) | .mfa() | Apply prefix to all MFA URLs + auto securityMatcher |
.defaultLoginUrl(String) | .formLogin() | Change auto-generated form URL (loginProcessingUrl auto-synced) |
.order(int) | .mfa() | SecurityFilterChain priority (lower = higher priority) |
Important Notes
When Multi MFA flows use different secondary factors (e.g., Flow 1 uses Passkey, Flow 2 uses OTT), you must separate URL scopes using
urlPrefix() or rawHttp(http -> http.securityMatcher(...)). Without separation, secondary authentication requests from one flow may be processed by another flow's SecurityFilterChain, resulting in failure.
MFA + Single Authentication Co-configuration
Register both MFA and single authentication together to apply different authentication levels to different request paths.
registry
// Admin: Form + OAuth2 (Single Auth)
.form(f -> f.order(20).loginPage("/admin/login")
.rawHttp(http -> http.securityMatcher("/admin/**")))
.oauth2(Customizer.withDefaults())
// User: MFA (Form Primary + Passkey) + Session
.mfa(m -> m.order(100)
.primaryAuthentication(auth -> auth
.formLogin(form -> form.loginPage("/login")))
.passkey(Customizer.withDefaults()))
.session(Customizer.withDefaults())
.build();
Use order() to control filter chain priority, and rawHttp()'s securityMatcher() to determine which chain handles which requests. Different state management (session/oauth2) can be configured for each authentication method.
MFA Handlers
Custom handlers can be specified for MFA success and failure.
.mfa(mfa -> mfa
.mfaSuccessHandler(customSuccessHandler)
.mfaFailureHandler(customFailureHandler)
.primaryAuthentication(primary -> primary
.formLogin(form -> form.loginPage("/login")))
.passkey(Customizer.withDefaults())
)
For handler interfaces and implementation guidance, refer to the handler auto-selection section in the Authentication documentation.
MFA REST API
| Method | URL | Description |
|---|---|---|
GET | /api/mfa/config | Retrieve MFA configuration |
POST | /mfa/select-factor | Select MFA factor |
POST | /mfa/ott/generate-code | Generate MFA OTT code |
POST | /login/mfa-ott | Process MFA OTT login |
POST | /login/mfa-webauthn | MFA Passkey authentication |
POST | /mfa/cancel | Cancel MFA |
Response Format
| Scenario | Response |
|---|---|
| Authentication success | { "success": true, "redirectUrl": "..." } |
| MFA required | { "mfaRequired": true, "selectFactorUrl": "...", "mfaSessionId": "..." } |
| Authentication failure | { "success": false, "error": "...", "failureType": "..." } |
| Step-up required | 401 MFA_CHALLENGE_REQUIRED |
ContexaMFA JavaScript SDK
A JavaScript SDK (v2.1.0) that controls the MFA flow in the browser.
Quick Start
<script src="/en/js/contexa-mfa-sdk.js?v=1776136758910"></script>
<script>
const mfa = new ContexaMFA.Client({
autoInit: true,
tokenPersistence: 'localStorage'
});
mfa.loginForm('username', 'password')
.then(result => { /* handle result */ });
</script>
Client API
| Method | Return Type | Description |
|---|---|---|
init() | Promise | Initialize SDK, load server configuration |
login(username, password) | Promise<LoginResult> | JSON login request |
loginForm(username, password) | Promise<LoginResult> | Form POST login |
selectFactor(factorType) | Promise<FactorResult> | Select MFA factor |
verifyOtt(token) | Promise<VerifyResult> | Verify OTT token |
verifyPasskey() | Promise<VerifyResult> | WebAuthn Passkey authentication |
requestOttCode() | Promise<RequestResult> | Request OTT code resend using the configured requestOttCode endpoint |
logout() | Promise | Logout |
Constructor Options
| Option | Type | Description |
|---|---|---|
autoInit | boolean | Whether to auto-initialize |
autoRedirect | boolean | Auto-redirect after authentication |
tokenPersistence | 'memory' | 'localStorage' | 'sessionStorage' | Token storage strategy (memory, localStorage, or sessionStorage) |
Global Interceptor
Automatically detects 401/403/423 responses from all fetch/XMLHttpRequest calls and redirects to the Step-up authentication page. After authentication completes, the original request is automatically retried.
The
spring.auth.mfa.* namespace provides MFA configuration properties including timeouts (sessionTimeoutMs, challengeTimeoutMs), retry settings (maxRetryAttempts, accountLockoutDurationMs), OTP settings (otpTokenValiditySeconds, otpTokenLength), repository selection, and page URLs. See the State Management documentation for the complete list.
spring.auth.urls.mfa.status exists as a configurable URL slot in the runtime configuration model, but the current OSS runtime does not expose a dedicated /api/mfa/status controller endpoint.