As independent second-factor
You can use Secrets Vault Identity to add a strong visual second factor of authentication to an existing login flow where the user has already been authenticated with a primary factor (e.g., username and password).
General Description
This flow is initiated by including the custom scope 2fa in the authorization request. The Secrets Vault Identity authentication process then acts as the second factor. In this flow, the server will only issue an id_token.
Integration Steps
- First Factor Authentication: First, authenticate the user with your existing primary method (e.g., username/password). This establishes the user's initial identity.
- Construct 2FA Authorization URL: After the first factor is successful, redirect the user's browser to the Secrets Vault Identity Authorization Endpoint. The request must include:
scope:openid 2fa.login_hint: The username or unique identifier of the user who just completed the first factor. This is strongly recommended as it pre-populates Secrets Vault Identity' context for the authentication challenge.- All other required OIDC parameters (
client_id,redirect_uri,state,nonce, and PKCE parameters even if it's a confidential client).
- Handle the Secrets Vault Identity Callback: The user completes the Visual Auth challenge at Secrets Vault Identity and is redirected back to your
redirect_uriwith anauthorization_code. - Exchange Code for ID Token: Your application's backend exchanges this
authorization_codeat the Secrets Vault Identity Token Endpoint, just as in a regular flow. - Receive ID Token (Only): Secrets Vault Identity will validate the request and respond with a JSON object where only the
id_tokenhas a value. Confirm thataccess_tokenandrefresh_tokenare empty. - Validate ID Token: Your backend must validate the
id_token's signature using Secrets Vault Identity' public key from the JWKS Endpoint, with the signing algorithm configured for your client. You must also validate thenonceto prevent replay attacks. - Complete Login: Once the
id_tokenis successfully validated, your application can consider the user fully authenticated with 2FA and proceed with establishing their final session within your system.
Prerequisites
- Client Registration: The 3rd party developer must register their application with the OpenID Connect provider. This involves providing application details (name, description, etc.) and specifying a redirect URI. The server will issue a
client_idand aclient_secret. - Signing Algorithm: Each client signs its tokens with the Algorithm selected in Client Management:
EdDSA(default) orRS256. Your token validation must use the same algorithm. - PKCE Implementation: The 3rd party application must implement PKCE. Requests with the
2fascope and nocode_challengeare rejected. - User already authenticated: We assume that the user has been already authenticated with a first factor (e.g., username + Password). That way, the identity of the user (e.g., username) is already known and the second factor is based on this identity.
When the tenant is configured for 2FA, every authorization request must include the 2fa scope. Requests without it are rejected with error=invalid_request (2fa scope required).
Authorization Code Flow with PKCE
Authorization Request with 2fa Scope
The 3rd party client application (belonging to the external party) initiates the second factor authorization code flow by redirecting the user to Image-based authentication Identify Provider (Secrets Vault Identity) server's authorization endpoint. In this request, the scope parameter includes the custom 2fa scope, in addition to other standard scopes like openid. For example:
GET /oidc/authorize/{tenant_id}?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}&scope=openid%202fa&code_challenge={code_challenge}&code_challenge_method=S256&nonce={nonce}&state={state}&login_hint={login_hint}&email_hint={login_hint}
The endpoint call also includes the user identifier (username) of the user that needs to be authenticated with the image.
Users who have not enrolled yet
A user who has not registered a visual credential yet (for example, a user just provisioned with SCIM) must go through enrollment first. Add the 2fa_link scope and prompt=create to the same request:
GET /oidc/authorize/{tenant_id}?...&scope=openid%202fa%202fa_link&prompt=create&login_hint={login_hint}
The 2fa_link scope is only accepted for confidential clients. Users created through SCIM receive an email with a verification code when their account is created.
Processing by the Authorization Server
Upon receiving the authorization request with the 2fa scope, Secrets Vault Identity server's authorization server recognizes that 2FA is required for the external party's user. The server then performs the following steps:
- Authentication (Treated as 2FA): In this specific implementation, the standard user authentication process at Secrets Vault Identity server is treated as the de facto second factor of authentication for the external party. The server initiates the authentication process, forcing the external party's user to log in using credentials recognized by this server. This login process serves as the 2FA step.
- 2FA Challenge: Since the primary authentication is the 2FA, there is no separate 2FA challenge step in this flow.
- 2FA Verification: The user provides their login credentials. This server verifies these credentials as part of its standard authentication process, effectively fulfilling the 2FA requirement.
- Token Issuance:
- If the authentication is successful, this server proceeds to issue an ID token.
- Critically, in this specific 2FA flow, this server is configured to only issue an ID token. The
access_tokenandrefresh_tokenfields of the response are empty. This is a key characteristic of this 2FA implementation.
Token Response
After the user completes the challenge, this server redirects the browser to the client's redirect URI with the authorization code:
HTTP/1.1 302 Found
Location: {redirect_uri}?code={authorization_code}&state={state}
The client's backend exchanges the code at the token endpoint and receives:
{
"id_token": "{id_token_value}",
"access_token": "",
"refresh_token": "",
"token_type": "Bearer",
"expires_in": 3600
}
- Important: Note that
access_tokenandrefresh_tokenare empty. Only theid_tokencan be used.
The id_token contains, among others, these claims:
| Claim | Value |
|---|---|
sub | The user's id in Secrets Vault Identity (the same id returned by SCIM). |
name | The user's username (the SCIM userName, in lowercase). |
email | The user's email. |
aud | Your client_id. |
nonce | The nonce sent in the authorization request. |
amr, acr | The authentication methods used and the resulting level of assurance, when available. |
Client Application Handling
The client application (of the external party) receives the ID token.
- The client application uses the ID token to obtain information about the user, and to confirm that the user has successfully authenticated with 2FA.
- Since no refresh token is provided, the client application will need to re-authenticate the user (and they will have to go through the 2FA process again with this server) once the ID token expires.
- It is strongly recommended that the client application perform thorough validation of the response received from this server in its backend. This is crucial for maintaining the security of the authentication process.
- Specifically, the client application should validate the nonce parameter present in the ID token against the nonce that was included in the original authorization request. This validation is essential to mitigate replay attacks, where an attacker might attempt to use a previously obtained, valid ID token.
- The client application should validate the ID token signature using the server public key, with the algorithm configured for the client (
EdDSAorRS256). Reject tokens signed with any other algorithm. - The client application should check that the user in the ID token (
suborname) is the same user who passed the first factor.
Endpoint Implementation Details
To implement this 2FA flow, the external party's application needs to implement two key endpoints:
Authorization Request Endpoint
- This endpoint is responsible for initiating the OpenID Connect authorization code flow with this server.
- The external party's backend (acting as a confidential client) constructs the authorization URL, including the necessary parameters:
response_type: Set to code.client_id: The client ID of the external party's application, as registered with this server.redirect_uri: A URI under the control of the external party's application, where this server will redirect the user after authentication.scope: Must include openid and the custom2fascope (e.g.,openid 2fa).code_challengeandcode_challenge_method: To enable PKCE (Proof Key for Code Exchange), the client generates acode_verifier, transforms it into acode_challenge, and includes thecode_challengeandcode_challenge_methodset to S256 in the request.nonce: Include a unique value to mitigate replay attacks.state: Include a unique value to maintain state between the request and callback.login_hint: Strongly recommended: Include the username of the user being authenticated. This binds the authentication request to a specific user.
- The external party's backend then redirects the user's browser to this constructed URL at this server's authorization endpoint.
Callback Endpoint
- This endpoint is the
redirect_urispecified in the authorization request. This server redirects the user's browser to this endpoint after the user has authenticated (and authorized the application). - This server includes the authorization
codeand thestatein the query parameters of the redirect URL. - The external party's backend at this endpoint:
- Retrieves the
codeand the state from the query parameters. - Important: Verify that the state parameter matches the state parameter from the original authorization request. This is crucial for preventing CSRF attacks.
- Sends a POST request to this server's token endpoint to exchange the code for an ID token. This request MUST include:
grant_type: Set toauthorization_code.code: The authorization code received from this server.redirect_uri: The sameredirect_uriused in the authorization request.client_id: The client ID of the external party's application.client_secret: The client secret for the external party's application (this is why this exchange must happen on the backend, not in the user's browser).code_verifier: The value generated in the Authorization Request Endpoint.
- This server's token endpoint validates the request (including the
code_verifierif PKCE was used) and, upon successful validation, returns an ID token. - The external party's backend then validates the ID token:
- Verifies the signature of the ID token.
- Validates the claims in the ID token, including issuer, audience, expiration, and nonce. The nonce value MUST match the one sent in the original authorization request.
- If the ID token is valid, the user is considered successfully authenticated with 2FA.
- Retrieves the
State Parameter Usage
- The state parameter is crucial for security and data integrity in the authorization code flow. It allows the external party's application to:
- Prevent CSRF Attacks: By including a unique, unpredictable value in the authorization request and verifying it in the callback, the application can ensure that the user is not being tricked into authorizing an unintended request.
- Correlate Request and Response: The state parameter links the authorization request with the corresponding callback, ensuring that the received authorization code is associated with the original request.
- In the context of this 2FA implementation, the external party's application should use the state parameter to bind it with other session-specific data, such as:
- The
noncevalue. - The
code_verifiervalue. - Any other data relevant to the external party's 2FA handling.
- The
- This binding can be achieved by:
- Creating a server-side session or storage associated with the state value.
- Storing the
nonce,code_verifier, and other data in this session or storage, indexed by the state value.
- Upon receiving the callback from this server, the external party's application retrieves the data associated with the state parameter and uses it to validate the response and proceed with the 2FA flow.
Code Examples
The second factor changes both your backend and your front end. They only talk to each other through the endpoints below, so you can combine any backend example with any front end example.
Because the visual challenge is a full-page redirect, the login XHR can no longer return the JWT directly. The JWT never travels in a URL: after the callback, the browser gets a short-lived, single-use handoff code and exchanges it with a POST.
The handoff code is bound to the browser that started the login, in the same way PKCE binds an authorization code: before the redirect, the front end creates a random handoffVerifier, keeps it in sessionStorage and sends only its SHA-256 hash (handoffChallenge) with the password. The backend releases the JWT only if the verifier sent with the handoff code matches that hash. A handoff code that leaks (browser history, logs, Referer header) or that an attacker plants in someone else's browser to sign them in to the attacker's account (login CSRF) cannot be redeemed without the verifier.
You can get the same binding with a random value in an HttpOnly, Secure, SameSite=Lax cookie set by POST /api/authenticate and checked by POST /api/2fa/complete. It needs no front end code, but your backend has to set and read that cookie. The examples below use the verifier, which works with any backend that already uses bearer tokens.
Front end and backend contract
| Step | Request | Response |
|---|---|---|
| 1 | POST /api/authenticate with the username, the password and handoffChallenge (base64url of the SHA-256 of a random handoffVerifier, 43 characters). | 200 { "twoFactorUrl": "..." }. The browser must navigate to that URL. |
| 2 | Secrets Vault Identity redirects the browser to GET /api/2fa/callback?code=...&state=... (your registered redirect URI). Handled by the backend only. | 302 to frontend-complete-url?handoff=..., or ?error=2fa_failed if the second factor failed. |
| 3 | POST /api/2fa/complete with { "handoff": "...", "handoffVerifier": "..." }. | 200 { "id_token": "<your JWT>" }, or 401 if the code is unknown, already used or expired, or the verifier does not match. |
The paths are examples; use the ones that fit your application, as long as both sides agree on them and on frontend-complete-url.
Backend
- Spring Boot (Java)
This example adds the second factor to a Spring Boot 3 / Java 21 application that already authenticates users with a password and issues its own JWT, such as a Spring Cloud Gateway (WebFlux) in a JHipster-style microservices platform. The application keeps its JWT model: Secrets Vault Identity sits between the password check and the moment the JWT is issued.
1. Prerequisites:
- A confidential client registered in the admin portal, with the callback (for example
https://gateway.example.com/api/2fa/callback) as redirect URI. Note theclient_id,client_secretand the Algorithm (EdDSAorRS256). - The issuer of your tenant:
https://your-server.com/api/oidc/{tenant_id}(Endpoints & discovery). - Every user must exist in your tenant with the same username you send as
login_hint. See the SCIM Java client to keep users in sync.
2. Dependencies:
The example verifies the id_token with Nimbus JOSE + JWT, which supports both algorithms. EdDSA (Ed25519) also needs Google Tink; you can leave it out if your client uses RS256.
<!-- Already on the classpath through spring-security-oauth2-jose; listed for clarity -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
<!-- Only for EdDSA: required by Nimbus for Ed25519 signature verification -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.15.0</version>
</dependency>
<!-- In-memory store for pending requests; replace it if you use another store -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
3. Configuration:
vidp:
issuer: https://your-server.com/api/oidc/<tenant_id>
client-id: <client_id>
# Keep secrets out of source control: inject them from the environment or a secret store.
client-secret: ${VIDP_CLIENT_SECRET}
# Must match the Algorithm configured for the client in the admin portal: EdDSA or RS256
signing-algorithm: EdDSA
redirect-uri: https://gateway.example.com/api/2fa/callback
frontend-complete-url: https://gateway.example.com/2fa/complete
# id_token claim that must match the user who passed the password check: name, sub or email
identity-claim: name
package com.mycompany.gateway.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "vidp")
public record VidpProperties(
String issuer,
String clientId,
String clientSecret,
@DefaultValue("EdDSA") String signingAlgorithm,
String redirectUri,
String frontendCompleteUrl,
@DefaultValue("name") String identityClaim,
@DefaultValue("5m") Duration pendingTtl,
@DefaultValue("60s") Duration clockSkew
) {}
Register it with @EnableConfigurationProperties(VidpProperties.class) on a configuration class.
4. OIDC client:
This component builds the authorization URL (PKCE, state, nonce, login_hint), exchanges the code and validates the id_token. It reads the endpoints from the discovery document, and only accepts tokens signed with the configured algorithm.
package com.mycompany.gateway.security.vidp;
import com.fasterxml.jackson.databind.JsonNode;
import com.mycompany.gateway.config.VidpProperties;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.Ed25519Verifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.OctetKeyPair;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
@Component
public class VidpOidcClient {
private static final SecureRandom RANDOM = new SecureRandom();
private final VidpProperties properties;
private final JWSAlgorithm algorithm;
private final WebClient webClient;
private volatile Discovery discovery;
private volatile JWKSet jwkSet;
public VidpOidcClient(VidpProperties properties) {
this.properties = properties;
this.algorithm = JWSAlgorithm.parse(properties.signingAlgorithm());
if (!JWSAlgorithm.EdDSA.equals(algorithm) && !JWSAlgorithm.RS256.equals(algorithm)) {
throw new IllegalStateException("vidp.signing-algorithm must be EdDSA or RS256");
}
// Plain builder on purpose: a @LoadBalanced builder would try to resolve the
// Secrets Vault Identity host through service discovery.
this.webClient = WebClient.builder().build();
}
public record Discovery(String issuer, String authorizationEndpoint, String tokenEndpoint, String jwksUri) {}
public record AuthorizationRequest(String url, String state, String nonce, String codeVerifier) {}
/**
* Builds the redirect URL for the visual second factor.
*
* @param loginHint the user's username in the Secrets Vault Identity tenant (SCIM userName)
* @param emailHint the user's email, may be null
* @param enrollment true for users who have not registered their visual credential yet
*/
public Mono<AuthorizationRequest> createAuthorizationRequest(String loginHint, String emailHint, boolean enrollment) {
String state = randomUrlSafe(32);
String nonce = randomUrlSafe(32);
String codeVerifier = randomUrlSafe(48);
Map<String, String> params = new LinkedHashMap<>();
params.put("response_type", "code");
params.put("client_id", properties.clientId());
params.put("redirect_uri", properties.redirectUri());
params.put("scope", enrollment ? "openid 2fa 2fa_link" : "openid 2fa");
if (enrollment) {
params.put("prompt", "create");
}
params.put("state", state);
params.put("nonce", nonce);
params.put("code_challenge", codeChallenge(codeVerifier));
params.put("code_challenge_method", "S256");
params.put("login_hint", loginHint);
if (emailHint != null) {
params.put("email_hint", emailHint);
}
return discovery().map(d -> new AuthorizationRequest(d.authorizationEndpoint() + "?" + toQuery(params), state, nonce, codeVerifier));
}
/** Exchanges the authorization code and returns the validated id_token claims. */
public Mono<JWTClaimsSet> exchangeCode(String code, String nonce, String codeVerifier) {
return discovery()
.flatMap(d ->
webClient
.post()
.uri(d.tokenEndpoint())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(
BodyInserters.fromFormData("grant_type", "authorization_code")
.with("code", code)
.with("redirect_uri", properties.redirectUri())
.with("client_id", properties.clientId())
.with("client_secret", properties.clientSecret())
.with("code_verifier", codeVerifier)
)
.retrieve()
.bodyToMono(JsonNode.class)
.onErrorMap(WebClientResponseException.class, e ->
new VidpAuthenticationException("Token exchange failed: " + e.getStatusCode(), e)
)
.flatMap(response -> {
// In the 2fa flow only the id_token is issued: access_token and refresh_token are empty.
if (!response.path("access_token").asText("").isEmpty() || !response.path("refresh_token").asText("").isEmpty()) {
return Mono.error(new VidpAuthenticationException("Not a 2fa token response"));
}
String idToken = response.path("id_token").asText("");
if (idToken.isEmpty()) {
return Mono.error(new VidpAuthenticationException("No id_token in token response"));
}
return validateIdToken(idToken, d, nonce);
})
);
}
private Mono<JWTClaimsSet> validateIdToken(String idToken, Discovery d, String expectedNonce) {
SignedJWT jwt;
try {
jwt = SignedJWT.parse(idToken);
} catch (ParseException e) {
return Mono.error(new VidpAuthenticationException("Malformed id_token", e));
}
// Only accept the algorithm configured for the client.
if (!algorithm.equals(jwt.getHeader().getAlgorithm())) {
return Mono.error(new VidpAuthenticationException("Unexpected id_token algorithm " + jwt.getHeader().getAlgorithm()));
}
return signingKey(d, jwt.getHeader().getKeyID()).flatMap(key ->
Mono.fromCallable(() -> {
if (!jwt.verify(verifierFor(key))) {
throw new VidpAuthenticationException("Invalid id_token signature");
}
JWTClaimsSet claims = jwt.getJWTClaimsSet();
Instant now = Instant.now();
Duration skew = properties.clockSkew();
require(d.issuer().equals(claims.getIssuer()), "iss mismatch");
require(claims.getAudience() != null && claims.getAudience().contains(properties.clientId()), "aud mismatch");
require(
claims.getExpirationTime() != null && now.minus(skew).isBefore(claims.getExpirationTime().toInstant()),
"id_token expired"
);
require(claims.getIssueTime() == null || claims.getIssueTime().toInstant().isBefore(now.plus(skew)), "iat in the future");
require(expectedNonce.equals(claims.getStringClaim("nonce")), "nonce mismatch");
return claims;
})
);
}
private static JWSVerifier verifierFor(JWK key) throws JOSEException {
if (key instanceof RSAKey rsaKey) {
return new RSASSAVerifier(rsaKey);
}
if (key instanceof OctetKeyPair okp) {
return new Ed25519Verifier(okp.toPublicJWK());
}
throw new JOSEException("Unsupported key type " + key.getKeyType());
}
private Mono<JWK> signingKey(Discovery d, String kid) {
JWK cached = findKey(jwkSet, kid);
if (cached != null) {
return Mono.just(cached);
}
// Unknown kid: the keys may have rotated, so fetch the JWKS again.
return webClient
.get()
.uri(d.jwksUri())
.retrieve()
.bodyToMono(String.class)
.map(body -> {
try {
return JWKSet.parse(body);
} catch (ParseException e) {
throw new VidpAuthenticationException("Invalid JWKS", e);
}
})
.doOnNext(set -> this.jwkSet = set)
.flatMap(set -> Mono.justOrEmpty(findKey(set, kid)))
.switchIfEmpty(Mono.error(new VidpAuthenticationException("No " + algorithm + " signing key for kid " + kid)));
}
private JWK findKey(JWKSet set, String kid) {
if (set == null) {
return null;
}
return set
.getKeys()
.stream()
// The JWKS also publishes encryption keys: keep only signing keys for the configured algorithm.
.filter(k -> KeyUse.SIGNATURE.equals(k.getKeyUse()))
.filter(k -> k.getAlgorithm() != null && algorithm.getName().equals(k.getAlgorithm().getName()))
.filter(k -> kid == null || kid.equals(k.getKeyID()))
.findFirst()
.orElse(null);
}
private Mono<Discovery> discovery() {
Discovery cached = discovery;
if (cached != null) {
return Mono.just(cached);
}
return webClient
.get()
.uri(properties.issuer() + "/.well-known/openid-configuration")
.retrieve()
.bodyToMono(JsonNode.class)
.map(json ->
new Discovery(
json.get("issuer").asText(),
json.get("authorization_endpoint").asText(),
json.get("token_endpoint").asText(),
json.get("jwks_uri").asText()
)
)
.doOnNext(d -> this.discovery = d);
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new VidpAuthenticationException(message);
}
}
private static String toQuery(Map<String, String> params) {
return params
.entrySet()
.stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
private static String codeChallenge(String codeVerifier) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private static String randomUrlSafe(int bytes) {
byte[] buffer = new byte[bytes];
RANDOM.nextBytes(buffer);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer);
}
}
package com.mycompany.gateway.security.vidp;
public class VidpAuthenticationException extends RuntimeException {
public VidpAuthenticationException(String message) {
super(message);
}
public VidpAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}
5. Store the state:
Between the password check and the callback, store who passed the first factor plus the nonce, code_verifier and handoffChallenge, indexed by state. Entries are single use and expire after a few minutes. The same store holds the one-time handoff codes, each with the handoffChallenge it must be redeemed with.
Any short-lived key-value store works: an in-memory cache, Redis, or a database table with an expiry. The example uses an in-memory Caffeine cache.
package com.mycompany.gateway.security.vidp;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.mycompany.gateway.config.VidpProperties;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
public class TwoFactorStateStore {
public record PendingTwoFactor(
String login,
String expectedIdentity,
List<String> authorities,
boolean rememberMe,
boolean enrollment,
String nonce,
String codeVerifier,
String handoffChallenge
) {}
private record Handoff(String jwt, String handoffChallenge) {}
private static final SecureRandom RANDOM = new SecureRandom();
private final Cache<String, PendingTwoFactor> pending;
private final Cache<String, Handoff> handoffs;
public TwoFactorStateStore(VidpProperties properties) {
this.pending = Caffeine.newBuilder().expireAfterWrite(properties.pendingTtl()).maximumSize(100_000).build();
this.handoffs = Caffeine.newBuilder().expireAfterWrite(Duration.ofSeconds(60)).maximumSize(100_000).build();
}
public void savePending(String state, PendingTwoFactor request) {
pending.put(state, request);
}
/** Returns the request and removes it, so a state value can be used only once. */
public Optional<PendingTwoFactor> consumePending(String state) {
return Optional.ofNullable(pending.asMap().remove(state));
}
public String saveHandoff(String jwt, String handoffChallenge) {
byte[] buffer = new byte[32];
RANDOM.nextBytes(buffer);
String code = Base64.getUrlEncoder().withoutPadding().encodeToString(buffer);
handoffs.put(code, new Handoff(jwt, handoffChallenge));
return code;
}
/**
* Returns the JWT only if the verifier matches the challenge sent with the password.
* The code is removed on the first attempt, even with a wrong verifier.
*/
public Optional<String> consumeHandoff(String code, String handoffVerifier) {
if (code == null || handoffVerifier == null) {
return Optional.empty();
}
Handoff handoff = handoffs.asMap().remove(code);
if (handoff == null) {
return Optional.empty();
}
byte[] expected = handoff.handoffChallenge().getBytes(StandardCharsets.US_ASCII);
byte[] actual = challengeOf(handoffVerifier).getBytes(StandardCharsets.US_ASCII);
return MessageDigest.isEqual(expected, actual) ? Optional.of(handoff.jwt()) : Optional.empty();
}
/** base64url(SHA-256(verifier)), the same transformation as PKCE S256. */
private static String challengeOf(String handoffVerifier) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(handoffVerifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
An in-memory store only works if the callback reaches the same instance that started the flow. With more than one instance, use a shared store (for example Redis with a TTL) or sticky sessions.
6. Issue your JWT after the second factor:
Move your existing token creation (in JHipster, AuthenticateController.createToken(...)) into a reusable component. The only addition is the amr claim, which records that both factors were used.
amr claim is optional, but recommendedThe integration works without it: the gateway only issues the JWT after the second factor succeeds. Adding it lets your services tell 2FA tokens apart from tokens issued before the integration, and is required if you want to enforce 2FA in the microservices (step 9). To skip it, drop the amr parameter and the .claim(AMR_CLAIM, amr) line.
package com.mycompany.gateway.security;
import static com.mycompany.gateway.security.SecurityUtils.AUTHORITIES_KEY;
import static com.mycompany.gateway.security.SecurityUtils.JWT_ALGORITHM;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Component;
@Component
public class JwtTokenIssuer {
public static final String AMR_CLAIM = "amr";
private final JwtEncoder jwtEncoder;
private final long tokenValidityInSeconds;
private final long tokenValidityInSecondsForRememberMe;
public JwtTokenIssuer(
JwtEncoder jwtEncoder,
@Value("${jhipster.security.authentication.jwt.token-validity-in-seconds:0}") long tokenValidityInSeconds,
@Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me:0}") long tokenValidityInSecondsForRememberMe
) {
this.jwtEncoder = jwtEncoder;
this.tokenValidityInSeconds = tokenValidityInSeconds;
this.tokenValidityInSecondsForRememberMe = tokenValidityInSecondsForRememberMe;
}
public String issue(String login, Collection<String> authorities, boolean rememberMe, List<String> amr) {
Instant now = Instant.now();
Instant validity = now.plusSeconds(rememberMe ? tokenValidityInSecondsForRememberMe : tokenValidityInSeconds);
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuedAt(now)
.expiresAt(validity)
.subject(login)
.claim(AUTHORITIES_KEY, String.join(" ", authorities))
.claim(AMR_CLAIM, amr) // optional, recommended
.build();
JwsHeader header = JwsHeader.with(JWT_ALGORITHM).build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
7. Two-factor service:
The service starts the flow after the password check and completes it after the callback. It uses two fields on your User entity: vidpUserId (the SCIM id, equal to the sub claim) and vidpEnrolled (whether the user has registered a visual credential).
package com.mycompany.gateway.security.vidp;
import com.mycompany.gateway.config.VidpProperties;
import com.mycompany.gateway.domain.User;
import com.mycompany.gateway.repository.UserRepository;
import com.mycompany.gateway.security.JwtTokenIssuer;
import com.mycompany.gateway.security.vidp.TwoFactorStateStore.PendingTwoFactor;
import java.util.List;
import java.util.Locale;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class TwoFactorService {
public record TwoFactorChallenge(String twoFactorUrl) {}
private final VidpOidcClient oidcClient;
private final TwoFactorStateStore store;
private final JwtTokenIssuer tokenIssuer;
private final UserRepository userRepository;
private final VidpProperties properties;
public TwoFactorService(
VidpOidcClient oidcClient,
TwoFactorStateStore store,
JwtTokenIssuer tokenIssuer,
UserRepository userRepository,
VidpProperties properties
) {
this.oidcClient = oidcClient;
this.store = store;
this.tokenIssuer = tokenIssuer;
this.userRepository = userRepository;
this.properties = properties;
}
/** Called after the password check succeeds. Returns the URL the browser must open. */
public Mono<TwoFactorChallenge> start(Authentication authentication, boolean rememberMe, String handoffChallenge) {
List<String> authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
return userRepository
.findOneByLogin(authentication.getName())
.switchIfEmpty(Mono.error(new VidpAuthenticationException("Unknown user " + authentication.getName())))
.flatMap(user -> {
boolean enrollment = !user.isVidpEnrolled();
return oidcClient
.createAuthorizationRequest(user.getLogin(), user.getEmail(), enrollment)
.map(request -> {
store.savePending(
request.state(),
new PendingTwoFactor(
user.getLogin(),
expectedIdentity(user),
authorities,
rememberMe,
enrollment,
request.nonce(),
request.codeVerifier(),
handoffChallenge
)
);
return new TwoFactorChallenge(request.url());
});
});
}
/** Called from the callback. Returns a one-time handoff code for the front end. */
public Mono<String> complete(String code, String state) {
PendingTwoFactor pending = store.consumePending(state).orElse(null);
if (pending == null) {
return Mono.error(new VidpAuthenticationException("Unknown or expired state"));
}
return oidcClient
.exchangeCode(code, pending.nonce(), pending.codeVerifier())
.flatMap(claims -> {
// Bind the second factor to the user who passed the first one.
Object identity = claims.getClaim(properties.identityClaim());
if (identity == null || pending.expectedIdentity() == null || !pending.expectedIdentity().equalsIgnoreCase(identity.toString())) {
return Mono.error(new VidpAuthenticationException("id_token belongs to a different user"));
}
return pending.enrollment() ? markEnrolled(pending.login()) : Mono.<Void>empty();
})
.then(
Mono.fromCallable(() -> {
String jwt = tokenIssuer.issue(pending.login(), pending.authorities(), pending.rememberMe(), List.of("pwd", "mfa"));
return store.saveHandoff(jwt, pending.handoffChallenge());
})
);
}
private String expectedIdentity(User user) {
return switch (properties.identityClaim()) {
case "sub" -> user.getVidpUserId();
case "email" -> user.getEmail();
// "name" holds the username, which Secrets Vault Identity stores in lowercase.
default -> user.getLogin().toLowerCase(Locale.ROOT);
};
}
private Mono<Void> markEnrolled(String login) {
return userRepository
.findOneByLogin(login)
.flatMap(user -> {
user.setVidpEnrolled(true);
return userRepository.save(user);
})
.then();
}
}
8. Controllers:
Add the handoffChallenge sent by the front end to the login request:
/** base64url(SHA-256(handoffVerifier)), created by the front end before the redirect. */
@NotNull
@Pattern(regexp = "^[A-Za-z0-9_-]{43}$")
private String handoffChallenge;
// ...existing fields, plus a getter and setter for handoffChallenge
Keep the password check in POST /api/authenticate, but return the second-factor URL instead of the JWT:
@PostMapping("/authenticate")
public Mono<ResponseEntity<TwoFactorService.TwoFactorChallenge>> authorize(@Valid @RequestBody Mono<LoginVM> loginVM) {
return loginVM
.flatMap(login ->
authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(login.getUsername(), login.getPassword()))
// First factor OK: do not issue the JWT yet, start the visual second factor.
.flatMap(authentication -> twoFactorService.start(authentication, login.isRememberMe(), login.getHandoffChallenge()))
)
.map(ResponseEntity::ok);
}
Add the callback (the registered redirect_uri) and the handoff endpoint:
package com.mycompany.gateway.web.rest;
import com.mycompany.gateway.config.VidpProperties;
import com.mycompany.gateway.security.vidp.TwoFactorService;
import com.mycompany.gateway.security.vidp.TwoFactorStateStore;
import java.net.URI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/2fa")
public class TwoFactorController {
private static final Logger log = LoggerFactory.getLogger(TwoFactorController.class);
public record HandoffRequest(String handoff, String handoffVerifier) {}
private final TwoFactorService twoFactorService;
private final TwoFactorStateStore store;
private final VidpProperties properties;
public TwoFactorController(TwoFactorService twoFactorService, TwoFactorStateStore store, VidpProperties properties) {
this.twoFactorService = twoFactorService;
this.store = store;
this.properties = properties;
}
@GetMapping("/callback")
public Mono<ResponseEntity<Void>> callback(
@RequestParam(required = false) String code,
@RequestParam(required = false) String state,
@RequestParam(required = false) String error
) {
if (error != null || code == null || state == null) {
if (state != null) {
store.consumePending(state);
}
return Mono.just(redirectToApp("error", "2fa_failed"));
}
return twoFactorService
.complete(code, state)
.map(handoff -> redirectToApp("handoff", handoff))
// Any failure (rejected token, network error, expired state) ends in the same generic error for the user.
.onErrorResume(e -> {
log.warn("Second factor rejected: {}", e.getMessage());
return Mono.just(redirectToApp("error", "2fa_failed"));
});
}
/** The front end exchanges the one-time handoff code, plus its verifier, for the JWT. */
@PostMapping("/complete")
public Mono<ResponseEntity<AuthenticateController.JWTToken>> complete(@RequestBody HandoffRequest request) {
return Mono.justOrEmpty(store.consumeHandoff(request.handoff(), request.handoffVerifier()))
.map(jwt -> {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(jwt);
return new ResponseEntity<>(new AuthenticateController.JWTToken(jwt), headers, HttpStatus.OK);
})
.defaultIfEmpty(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}
private ResponseEntity<Void> redirectToApp(String param, String value) {
URI location = UriComponentsBuilder.fromUriString(properties.frontendCompleteUrl()).queryParam(param, value).build().toUri();
return ResponseEntity.status(HttpStatus.FOUND).location(location).build();
}
}
Allow both endpoints without a JWT in your security configuration, like /api/authenticate, and apply your rate limiting to them:
.pathMatchers("/api/authenticate").permitAll()
.pathMatchers("/api/2fa/callback", "/api/2fa/complete").permitAll()
9. Enforce 2FA in the microservices (optional):
Services that validate your JWT need no change. If your JWT includes the amr claim (step 6), you can also reject tokens issued without the second factor by adding a validator for it to their JwtDecoder (and to the gateway's):
public class MfaClaimValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error MISSING_MFA = new OAuth2Error(
"invalid_token",
"Token was not issued after the Secrets Vault Identity second factor",
null
);
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
List<String> amr = jwt.getClaimAsStringList("amr");
return amr != null && amr.contains("mfa") ? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(MISSING_MFA);
}
}
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(getSecretKey()).macAlgorithm(JWT_ALGORITHM).build();
jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefault(), new MfaClaimValidator()));
NimbusReactiveJwtDecoder has the same setJwtValidator method. Turn the validator on once all JWTs issued without the amr claim have expired, or users with an open session will get 401 responses.
Front end
With the second factor, login is no longer a single request. Whatever framework you use, the front end changes in the same way:
- The handoff verifier: before sending the password, create a random
handoffVerifier, keep it insessionStorageand sendhandoffChallenge(its SHA-256, base64url-encoded) with the credentials. Send the verifier back with the handoff code. - The login response:
POST /api/authenticatereturns{ "twoFactorUrl": "..." }instead of the JWT. The front end must send the browser to that URL with a full page navigation, not an XHR/fetchcall or an iframe. - Login success moves to a new page: the SPA is unloaded during the second factor, so whatever ran after a successful login (storing the token, loading the account, navigating to the home page) now runs in the page at
frontend-complete-url, afterPOST /api/2fa/complete. Anything kept in memory before the redirect, such as "remember me" or the page the user wanted, must be saved insessionStoragefirst. - A new route: the page at
frontend-complete-urlreadshandoff(orerror) from the query string and exchanges the handoff code for the JWT. It must not require authentication. - Errors: if the second factor fails or is cancelled, the user arrives at that page with
?error=2fa_failedand no token. Send them back to the login page with a message.
- Angular
- React
This example extends an Angular front end (Angular 17 or greater, standalone components).
1. Follow the redirect on login:
On login, create the handoff verifier and send its hash with the password. Then follow the redirect instead of storing a token, and remember the "remember me" choice because the page reloads:
type TwoFactorChallenge = { twoFactorUrl: string };
const REMEMBER_ME_KEY = 'vidp-remember-me';
const HANDOFF_VERIFIER_KEY = 'vidp-handoff-verifier';
function base64Url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Creates a random verifier, keeps it in this tab and returns base64url(SHA-256(verifier)). */
async function createHandoffChallenge(): Promise<string> {
const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
sessionStorage.setItem(HANDOFF_VERIFIER_KEY, verifier);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64Url(new Uint8Array(hash));
}
login(credentials: Login): Observable<void> {
return from(createHandoffChallenge()).pipe(
switchMap(handoffChallenge =>
this.http.post<JwtToken | TwoFactorChallenge>(this.applicationConfigService.getEndpointFor('api/authenticate'), {
...credentials,
handoffChallenge,
}),
),
mergeMap(response => {
if ('twoFactorUrl' in response) {
sessionStorage.setItem(REMEMBER_ME_KEY, String(credentials.rememberMe));
// Leave the SPA: the browser goes to Secrets Vault Identity for the visual second factor.
window.location.assign(response.twoFactorUrl);
return NEVER;
}
sessionStorage.removeItem(HANDOFF_VERIFIER_KEY);
this.authenticateSuccess(response, credentials.rememberMe);
return of(undefined);
}),
);
}
completeTwoFactor(handoff: string): Observable<void> {
const rememberMe = sessionStorage.getItem(REMEMBER_ME_KEY) === 'true';
const handoffVerifier = sessionStorage.getItem(HANDOFF_VERIFIER_KEY);
sessionStorage.removeItem(REMEMBER_ME_KEY);
sessionStorage.removeItem(HANDOFF_VERIFIER_KEY);
return this.http
.post<JwtToken>(this.applicationConfigService.getEndpointFor('api/2fa/complete'), { handoff, handoffVerifier })
.pipe(map(response => this.authenticateSuccess(response, rememberMe)));
}
crypto.subtle is only available on HTTPS pages (and localhost). The verifier lives in sessionStorage, so the second factor must finish in the same tab that started it, which is what happens with the redirect.
2. Completion page:
Add a route for frontend-complete-url (here /2fa/complete) that exchanges the handoff code:
@Component({
standalone: true,
selector: 'jhi-two-factor-complete',
template: `<p>Completing sign-in…</p>`,
})
export default class TwoFactorCompleteComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly authServerProvider = inject(AuthServerProvider);
private readonly accountService = inject(AccountService);
ngOnInit(): void {
const handoff = this.route.snapshot.queryParamMap.get('handoff');
if (!handoff) {
this.router.navigate(['/login'], { queryParams: { error: '2fa' } });
return;
}
this.authServerProvider
.completeTwoFactor(handoff)
.pipe(mergeMap(() => this.accountService.identity(true)))
.subscribe({
next: () => this.router.navigate(['']),
error: () => this.router.navigate(['/login'], { queryParams: { error: '2fa' } }),
});
}
}
3. Route and login errors:
Register the route without the UserRouteAccessService guard, since the user has no token yet:
{
path: '2fa/complete',
loadComponent: () => import('./login/two-factor-complete.component'),
title: 'login.title',
},
In the login component, show a message when the user comes back with ?error=2fa:
private readonly route = inject(ActivatedRoute);
ngOnInit(): void {
this.authenticationError.set(this.route.snapshot.queryParamMap.get('error') === '2fa');
// ...existing code
}
The login component's next callback (which navigated to the home page) no longer runs on a successful password check, because login() never emits while the browser is redirecting. Keep it for users who do not need the second factor, and keep the loading state until the page unloads.
This example uses React 18 or greater, React Router 6 and fetch. It keeps the JWT in localStorage or sessionStorage; if your app stores it elsewhere (a context, Redux, an auth library), change storeToken.
1. Follow the redirect on login:
Create the handoff verifier and send its hash with the password. Then follow the redirect instead of storing a token, and remember the "remember me" choice because the page reloads:
type Credentials = { username: string; password: string; rememberMe: boolean };
type JwtToken = { id_token: string };
type TwoFactorChallenge = { twoFactorUrl: string };
const TOKEN_KEY = 'authenticationToken';
const REMEMBER_ME_KEY = 'vidp-remember-me';
const HANDOFF_VERIFIER_KEY = 'vidp-handoff-verifier';
function base64Url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Creates a random verifier, keeps it in this tab and returns base64url(SHA-256(verifier)). */
async function createHandoffChallenge(): Promise<string> {
const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
sessionStorage.setItem(HANDOFF_VERIFIER_KEY, verifier);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64Url(new Uint8Array(hash));
}
async function postJson<T>(url: string, body: unknown): Promise<T> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${url} failed with status ${response.status}`);
}
return response.json() as Promise<T>;
}
function storeToken(token: string, rememberMe: boolean): void {
(rememberMe ? localStorage : sessionStorage).setItem(TOKEN_KEY, token);
}
/**
* Returns 'redirecting' while the browser leaves for the second factor,
* or 'authenticated' for users who do not need it.
*/
export async function login(credentials: Credentials): Promise<'redirecting' | 'authenticated'> {
const handoffChallenge = await createHandoffChallenge();
const response = await postJson<JwtToken | TwoFactorChallenge>('/api/authenticate', { ...credentials, handoffChallenge });
if ('twoFactorUrl' in response) {
sessionStorage.setItem(REMEMBER_ME_KEY, String(credentials.rememberMe));
// Leave the SPA: the browser goes to Secrets Vault Identity for the visual second factor.
window.location.assign(response.twoFactorUrl);
return 'redirecting';
}
sessionStorage.removeItem(HANDOFF_VERIFIER_KEY);
storeToken(response.id_token, credentials.rememberMe);
return 'authenticated';
}
export async function completeTwoFactor(handoff: string): Promise<void> {
const rememberMe = sessionStorage.getItem(REMEMBER_ME_KEY) === 'true';
const handoffVerifier = sessionStorage.getItem(HANDOFF_VERIFIER_KEY);
sessionStorage.removeItem(REMEMBER_ME_KEY);
sessionStorage.removeItem(HANDOFF_VERIFIER_KEY);
const response = await postJson<JwtToken>('/api/2fa/complete', { handoff, handoffVerifier });
storeToken(response.id_token, rememberMe);
}
crypto.subtle is only available on HTTPS pages (and localhost). The verifier lives in sessionStorage, so the second factor must finish in the same tab that started it, which is what happens with the redirect.
2. Completion page:
Add a page for frontend-complete-url (here /2fa/complete) that exchanges the handoff code:
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { completeTwoFactor } from '../auth/twoFactor';
export default function TwoFactorComplete() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const started = useRef(false);
useEffect(() => {
// StrictMode runs effects twice in development, but a handoff code can only be used once.
if (started.current) {
return;
}
started.current = true;
const handoff = searchParams.get('handoff');
if (!handoff) {
navigate('/login?error=2fa', { replace: true });
return;
}
completeTwoFactor(handoff)
// Load the account here if your app does it after a normal login.
.then(() => navigate('/', { replace: true }))
.catch(() => navigate('/login?error=2fa', { replace: true }));
}, [navigate, searchParams]);
return <p>Completing sign-in…</p>;
}
replace: true also removes the page with the handoff code from the browser history.
3. Route and login errors:
Register the page outside your authentication guard, since the user has no token yet:
export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/2fa/complete', element: <TwoFactorComplete /> },
{
path: '/',
element: (
<RequireAuth>
<AppLayout />
</RequireAuth>
),
children: [
// ...your protected routes
],
},
]);
In the login page, show a message when the user comes back with ?error=2fa, and keep the loading state while the browser is redirecting:
import { FormEvent, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { login } from '../auth/twoFactor';
export default function LoginPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(searchParams.get('error') === '2fa');
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setLoading(true);
setError(false);
try {
const result = await login({
username: String(form.get('username')),
password: String(form.get('password')),
rememberMe: form.get('rememberMe') === 'on',
});
if (result === 'authenticated') {
navigate('/');
}
// 'redirecting': keep the loading state until the page unloads.
} catch {
setLoading(false);
setError(true);
}
}
// ...render the form, the error message and the loading state
}