contexa-identity

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.

Primary Auth Form / REST Login
MfaPolicyProvider
MFA Not Required
Auth Complete
MFA Required
Factor Selection OTT / Passkey
State Machine Challenge → Verify
MFA Success

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 Order
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

MethodFactorDescription
.ott()One-Time TokenEmail/SMS one-time code
.passkey()WebAuthn / PasskeyBiometric 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)
)
SettingBehavior
.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.
Factor Selection Behavior
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().

MethodDescription
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 and data-* 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

FileTarget Page
/js/contexa-mfa-sdk.jsAll MFA pages (required)
/js/contexa-login-page.jsLogin page
/js/contexa-ott-request-page.jsOTT code request page
/js/contexa-ott-verify-page.jsOTT code verification page
/js/contexa-passkey-page.jsPasskey challenge page

MFA Data Flow

POST /login Primary Authentication
/mfa/select-factor
OTT
/mfa/ott/request-code-ui
POST /mfa/ott/generate-code
/mfa/challenge/ott
POST /login/mfa-ott
Success Failure
Passkey
/mfa/challenge/passkey
navigator.credentials.get() WebAuthn API
POST /login/mfa-webauthn
Success Failure

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

ImplementationDescription
DefaultMfaPolicyProviderStatic rule-based. Determines MFA requirement based on user roles and MFA configuration
AIAdaptiveMfaPolicyProviderAI 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

DecisionTypeDescription
NO_MFA_REQUIREDMFA not required. Authentication completes immediately
CHALLENGEDMFA required. Secondary factor authentication demanded
BLOCKEDAuthentication blocked. Access denied
ESCALATEDEscalated 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.

Authenticated User Accesses sensitive resource
Server: 401 MFA_CHALLENGE_REQUIRED
SDK Global Interceptor Auto-detect 401 / 403 / 423 responses
MFA Factor Challenge Redirect to factor selection page
Auto-Retry Original request automatically retried

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

EventDescription
StateChangeEventMFA state transition (e.g., FACTOR_SELECTION -> OTT_CHALLENGE)
ErrorEventError occurred (SECURITY, TIMEOUT, LIMIT_EXCEEDED, SYSTEM)
PerformanceAlertEventPerformance warning (slow response, etc.)
CustomEventUser-defined event

Terminal States

StateDescription
MFA_SUCCESSFULMFA succeeded. Full authentication complete
MFA_NOT_REQUIREDMFA not required. Skipped by policy
MFA_FAILED_TERMINALMFA permanently failed. No retry allowed
MFA_CANCELLEDUser cancelled MFA
MFA_TIMEOUTSession timeout
MFA_SESSION_EXPIREDMFA session expired
MFA_ACCOUNT_LOCKEDAccount locked
MFA_SYSTEM_ERRORSystem 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.

Incoming Request
URL Pattern Match
/admin/**
Chain: SecurityFilterChain (order=60)
typeName: mfa
Factors: Passkey
URLs: /admin/mfa/login, /admin/mfa/select-factor
/user/**
Chain: SecurityFilterChain (order=70)
typeName: mfa_2
Factors: OTT
URLs: /user/mfa/login, /user/mfa/select-factor
Core Principle
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).

Java
// 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())
ValueBehaviorExample
Not set (default)All configured factors must be completed. If OTT + Passkey are registered, the user must complete both.OTT + Passkey configured → 2 required
1User completes any one factor (OTT or Passkey), then MFA succeeds.Standard 2FA: password + one additional factor
NUser 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 OrderAuto typeNameDescription
First .mfa()mfaSame as single MFA (backward compatible)
Second .mfa()mfa_2Auto-numbered
Third .mfa()mfa_3Auto-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 URLAfter 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
Automatic securityMatcher
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.

APIBehaviorAuto Form Generation
.loginPage("/custom")Redirects to custom page (Spring Security standard)No (user-provided page)
.defaultLoginUrl("/admin/mfa/login")Changes auto-generated form URLYes (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

MethodLocationDescription
.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

securityMatcher Required
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

MethodURLDescription
GET/api/mfa/configRetrieve MFA configuration
POST/mfa/select-factorSelect MFA factor
POST/mfa/ott/generate-codeGenerate MFA OTT code
POST/login/mfa-ottProcess MFA OTT login
POST/login/mfa-webauthnMFA Passkey authentication
POST/mfa/cancelCancel MFA

Response Format

ScenarioResponse
Authentication success{ "success": true, "redirectUrl": "..." }
MFA required{ "mfaRequired": true, "selectFactorUrl": "...", "mfaSessionId": "..." }
Authentication failure{ "success": false, "error": "...", "failureType": "..." }
Step-up required401 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

MethodReturn TypeDescription
init()PromiseInitialize 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()PromiseLogout

Constructor Options

OptionTypeDescription
autoInitbooleanWhether to auto-initialize
autoRedirectbooleanAuto-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.

MFA Configuration Properties
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.
Status Endpoint Note
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.