Spring Boot Authentication Methods
|
This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against. OAuth 2.1 is still an Internet-Draft ( This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Authentication Methods: Passwordless and 2FA describes, framework-neutrally, what an authorization server can use to identify a user. This page names the Spring Security 7.1 mechanism behind each of those methods for a service that runs its own authorization server — and, just as importantly, says plainly which ones Spring does not implement for you.
It keeps that page’s two axes rather than flattening them into one list:
|
A primary factor is what identifies the user in the first place — a password, or one of the passwordless
methods below. 2FA/MFA is an additional security layer stacked on a primary factor: it is never a primary
method in its own right, and it is never "passwordless" by itself. Enabling |
Form login with DaoAuthenticationProvider, UserDetailsService and PasswordEncoder — the password primary
factor — is already documented in depth in
Spring Security; it is not repeated here.
Passwordless primary methods: what Spring gives you
These replace the password as the primary factor.
| Method (protocol page) | Spring Security 7.1 mechanism | Built in? |
|---|---|---|
|
Yes |
|
|
Yes |
|
|
Yes (delivery is yours) |
|
— (no built-in support; custom |
No |
|
|
Yes |
|
|
Partial |
|
|
Yes |
|
|
Yes |
Additional 2FA/MFA layers: what Spring gives you
None of these is a primary method. Each is stacked on one of the rows above.
| Layer (protocol page) | Spring Security 7.1 mechanism | Built in? |
|---|---|---|
Requiring more than one factor at all |
|
Yes |
One-time token as a second factor |
The same |
Yes |
Passkey as a second factor |
The same |
Yes |
TOTP/HOTP authenticator apps |
— (no built-in support; custom factor) |
No |
SMS/e-mail OTP as a second factor |
|
Yes (delivery is yours) |
Push approval as a second factor |
— (no built-in support; custom factor) |
No |
Recovery codes |
— (application concern) |
No |
One-time token login
oneTimeTokenLogin() is the mechanism behind the e-mail code, the magic link and the SMS code — and, when
required as a second factor, behind OTP-as-2FA. Spring generates, stores and consumes the token; delivering
it is deliberately left to you, because Spring has no opinion about your mail or SMS provider.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/**", "/ott/**").permitAll()
.anyRequest().authenticated())
.oneTimeTokenLogin(ott -> ott
.tokenGenerationSuccessHandler(magicLinkHandler()));
return http.build();
}
The moving parts:
-
OneTimeTokenService— generates and consumes tokens.InMemoryOneTimeTokenServiceis the default and is suitable only for a single instance; useJdbcOneTimeTokenServicein production so tokens survive a restart and are shared across instances. -
OneTimeTokenGenerationSuccessHandler—handle(HttpServletRequest, HttpServletResponse, OneTimeToken). This is where you mail or text the token. It is the only piece you must write for a working magic link. -
GenerateOneTimeTokenRequestResolver(defaultDefaultGenerateOneTimeTokenRequestResolver) — customizes the generate request, including the token’s time-to-live.
Default endpoints: POST /ott/generate (configurable with tokenGeneratingUrl(…)) and the submit page at
GET /login/ott (configurable with defaultSubmitPageUrl(…)).
A magic-link handler that mails a link rather than a bare code:
@Bean
OneTimeTokenGenerationSuccessHandler magicLinkHandler() {
return (request, response, oneTimeToken) -> {
String link = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
.replacePath(request.getContextPath() + "/login/ott")
.replaceQuery("token=" + oneTimeToken.getTokenValue())
.toUriString();
mailSender.send(oneTimeToken.getUsername(), "Your sign-in link", link); (1)
response.setStatus(HttpStatus.OK.value()); (2)
};
}
| 1 | Deliver out of band. Never write the token value into the HTTP response. |
| 2 | Answer identically whether or not the account exists — see the enumeration note below. |
Three things Spring does not do for you here, all of them called out as requirements on the protocol page:
-
Rate limiting and lockout on
POST /ott/generate. Without it the endpoint is a free mail/SMS cannon pointed at your users and your budget. -
Enumeration-safe responses. Return the same status and body whether or not the address is known.
-
TTL tuning. Set it deliberately through the request resolver; a magic link that lives for hours is a standing credential in an inbox.
Passkeys
webAuthn() makes the service a WebAuthn relying party. Add spring-security-webauthn.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.webAuthn(webAuthn -> webAuthn
.rpId("example.com") (1)
.rpName("Example")
.allowedOrigins("https://example.com")); (2)
return http.build();
}
| 1 | The relying-party ID is the registrable domain. It is what binds a credential to your origin, and it is
why passkeys are phishing-resistant — a credential registered for example.com simply cannot be used by
examp1e.com. |
| 2 | Must list every origin the ceremony may run from, including ports in development. |
Persistence is two repositories, and the in-memory defaults are for demos only:
-
PublicKeyCredentialUserEntityRepository— the WebAuthn user handle.JdbcPublicKeyCredentialUserEntityRepositoryfor production. -
UserCredentialRepository— the registered credentials themselves.JdbcUserCredentialRepositoryfor production. -
PublicKeyCredentialCreationOptionsRepository— holds the in-flight registration challenge; defaults to theHttpSession, which is usually right.
Default endpoints: POST /webauthn/register/options and POST /webauthn/register to enrol, then
POST /webauthn/authenticate/options and POST /login/webauthn to sign in. The generated login page offers
passkey sign-in once webAuthn() is enabled.
Multi-factor authentication
Spring Security 7 models each completed authentication step as a factor authority on the resulting
Authentication, taken from FactorGrantedAuthority: PASSWORD_AUTHORITY, OTT_AUTHORITY,
WEBAUTHN_AUTHORITY, X509_AUTHORITY, AUTHORIZATION_CODE_AUTHORITY. Requiring more than one factor is then
just an authorization rule, and Spring handles the redirecting between factor login pages.
The declarative form:
@Configuration
@EnableWebSecurity
@EnableMultiFactorAuthentication(authorities = {
FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.OTT_AUTHORITY })
public class SecurityConfig { }
The bean form, which is the one to reach for when the rule is conditional:
@Bean
AuthorizationManagerFactory<Object> authz() {
return AuthorizationManagerFactories.multiFactor()
.requireFactors(
FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.OTT_AUTHORITY)
.build();
}
A factor can carry a validity window, which is how "you authenticated with your password an hour ago, do it again" is expressed:
AuthorizationManagerFactories.multiFactor()
.requireFactor(factor -> factor
.passwordAuthority()
.validDuration(Duration.ofMinutes(30)))
.build();
Passwordless and multi-factor
This is the combination the protocol page recommends, and Spring expresses it directly: accept either a passkey on its own or a one-time token plus a password — so a user with a passkey never types a password, and a user without one still gets two factors.
AllRequiredFactorsAuthorizationManager<Object> passkeyOnly =
AllRequiredFactorsAuthorizationManager.<Object>builder()
.requireFactor(factor -> factor.webauthnAuthority())
.build();
AllRequiredFactorsAuthorizationManager<Object> passwordAndOtt =
AllRequiredFactorsAuthorizationManager.<Object>builder()
.requireFactor(factor -> factor.passwordAuthority())
.requireFactor(factor -> factor.ottAuthority())
.build();
DefaultAuthorizationManagerFactory<Object> mfa = new DefaultAuthorizationManagerFactory<>();
mfa.setAdditionalAuthorization(
AllRequiredFactorsAuthorizationManager.anyOf(passkeyOnly, passwordAndOtt));
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/protected/**").access(mfa.authenticated())
.anyRequest().authenticated());
Note which branch is which: passkeyOnly is passwordless, passwordAndOtt is multi-factor but not
passwordless. Both are two-of-something in the security sense; only one of them removes the password from the
account. Swapping passwordAuthority() for ottAuthority() plus webauthnAuthority() would give a
passwordless and multi-factor branch.
For per-user rules — MFA for administrators only — use RequiredAuthoritiesAuthorizationManager with a
RequiredAuthoritiesRepository, or .when(…) on the factory.
Device trust, and what is not built in
rememberMe() is Spring’s "remember this device", and it is important to be precise about what it is: a
long-lived bearer cookie, not a device-bound key pair. Anyone who copies the cookie is the device. It is
reasonable for suppressing a second factor on a familiar browser; it is not a substitute for the
device-bound credentials described on
the protocol page, and it should never
stand in for the primary factor on a sensitive operation — require a fresh factor there instead, via
validDuration(…) above or step-up.
Spring Security has no built-in support for these, in either the primary or the second-factor role:
-
TOTP/HOTP authenticator apps — the single most commonly assumed "it must be in there somewhere" feature. It is not. Implement it as a custom factor (an
AuthenticationProviderplus a filter that contributes your ownGrantedAuthority, plugged into the MFA rules above) or use a third-party library. -
Push approval, in either role.
-
Recovery codes, which are an application concern — and, per the protocol page, the thing that quietly caps the assurance of every stronger method above them.
Saying this plainly matters more than it looks: a configuration that appears to enforce TOTP because it
mentions mfa() enforces nothing at all unless something actually contributes that factor’s authority.
Reporting the result to clients
None of the above is visible to a client — that is the point of doing authentication inside the authorization
server. What is visible is what you put in the token. Map the factor authorities onto the standard
RFC 8176 amr values, and set acr and auth_time, with an
OAuth2TokenCustomizer on your Spring Authorization Server:
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> idTokenCustomizer() {
return context -> {
if (!OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
return;
}
Collection<? extends GrantedAuthority> granted =
context.getPrincipal().getAuthorities();
List<String> amr = new ArrayList<>();
if (hasFactor(granted, FactorGrantedAuthority.PASSWORD_AUTHORITY)) amr.add("pwd");
if (hasFactor(granted, FactorGrantedAuthority.OTT_AUTHORITY)) amr.add("otp");
if (hasFactor(granted, FactorGrantedAuthority.WEBAUTHN_AUTHORITY)) amr.add("swk");
if (hasFactor(granted, FactorGrantedAuthority.X509_AUTHORITY)) amr.add("hwk");
if (amr.size() > 1) {
amr.add("mfa"); (1)
}
context.getClaims()
.claim("amr", amr)
.claim("acr", amr.contains("swk") ? "urn:example:aal2" : "urn:example:aal1")
.claim("auth_time", authenticationInstant(context).getEpochSecond());
};
}
| 1 | mfa means more than one factor was used — nothing more. It does not mean "no password was used". A
client that needs to know the login was passwordless must check for the absence of pwd, not the
presence of mfa. This is the single most common misreading of amr, and it is why the two axes are kept
apart on both this page and the protocol page. |
Resource servers then enforce an upgrade mid-session with an RFC 9470 step-up challenge — see Scopes, Claims and Permissions and Authentication Methods: Passwordless and 2FA.
Why none of this works when the client collects the password
Every mechanism on this page lives inside the authorization server, behind its own login pages. A client that collects credentials itself — the removed resource owner password credentials grant, or an embedded WebView in a native app — can reach none of it: no magic link, no passkey, no second factor, no step-up, no CAPTCHA, no risk engine. That is the concrete, product-level reason those patterns are deprecated, and it is why the authorization request has to happen in a browser the authorization server controls.