Skip to main content

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​

  1. First Factor Authentication: First, authenticate the user with your existing primary method (e.g., username/password). This establishes the user's initial identity.
  2. 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).
  3. Handle the Secrets Vault Identity Callback: The user completes the Visual Auth challenge at Secrets Vault Identity and is redirected back to your redirect_uri with an authorization_code.
  4. Exchange Code for ID Token: Your application's backend exchanges this authorization_code at the Secrets Vault Identity Token Endpoint, just as in a regular flow.
  5. Receive ID Token (Only): Secrets Vault Identity will validate the request and respond with a JSON object where only the id_token has a value. Confirm that access_token and refresh_token are empty.
  6. 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 the nonce to prevent replay attacks.
  7. Complete Login: Once the id_token is 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_id and a client_secret.
  • Signing Algorithm: Each client signs its tokens with the Algorithm selected in Client Management: EdDSA (default) or RS256. Your token validation must use the same algorithm.
  • PKCE Implementation: The 3rd party application must implement PKCE. Requests with the 2fa scope and no code_challenge are 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.
2FA tenants

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_token and refresh_token fields 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_token and refresh_token are empty. Only the id_token can be used.

The id_token contains, among others, these claims:

ClaimValue
subThe user's id in Secrets Vault Identity (the same id returned by SCIM).
nameThe user's username (the SCIM userName, in lowercase).
emailThe user's email.
audYour client_id.
nonceThe nonce sent in the authorization request.
amr, acrThe 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.
Security Recommendation
  • 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 (EdDSA or RS256). Reject tokens signed with any other algorithm.
  • The client application should check that the user in the ID token (sub or name) 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 custom 2fa scope (e.g., openid 2fa).
    • code_challenge and code_challenge_method: To enable PKCE (Proof Key for Code Exchange), the client generates a code_verifier, transforms it into a code_challenge, and includes the code_challenge and code_challenge_method set 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_uri specified 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 code and the state in the query parameters of the redirect URL.
  • The external party's backend at this endpoint:
    • Retrieves the code and 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 to authorization_code.
      • code: The authorization code received from this server.
      • redirect_uri: The same redirect_uri used 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_verifier if 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.

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 nonce value.
    • The code_verifier value.
    • Any other data relevant to the external party's 2FA handling.
  • 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.

Binding with a cookie instead

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​

StepRequestResponse
1POST /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.
2Secrets 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.
3POST /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​

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 the client_id, client_secret and the Algorithm (EdDSA or RS256).
  • 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.

pom.xml
<!-- 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:​

application.yml
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
config/VidpProperties.java
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.

security/vidp/VidpOidcClient.java
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);
}
}
security/vidp/VidpAuthenticationException.java
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.

security/vidp/TwoFactorStateStore.java
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);
}
}
}
Several instances

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.

The amr claim is optional, but recommended

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

security/JwtTokenIssuer.java
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).

security/vidp/TwoFactorService.java
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:

web/rest/vm/LoginVM.java
/** 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:

web/rest/AuthenticateController.java (authorize method)
@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:

web/rest/TwoFactorController.java
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:

config/SecurityConfiguration.java
.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):

security/MfaClaimValidator.java
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);
}
}
config/SecurityJwtConfiguration.java
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 in sessionStorage and send handoffChallenge (its SHA-256, base64url-encoded) with the credentials. Send the verifier back with the handoff code.
  • The login response: POST /api/authenticate returns { "twoFactorUrl": "..." } instead of the JWT. The front end must send the browser to that URL with a full page navigation, not an XHR/fetch call 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, after POST /api/2fa/complete. Anything kept in memory before the redirect, such as "remember me" or the page the user wanted, must be saved in sessionStorage first.
  • A new route: the page at frontend-complete-url reads handoff (or error) 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_failed and no token. Send them back to the login page with a message.

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:

core/auth/auth-jwt.service.ts
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:

login/two-factor-complete.component.ts
@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:

app.routes.ts
{
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:

login/login.component.ts
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.