Historically, passwords have been bad. They are leaked constantly, and with modern hardware, it is very easy to crack even the most securely encrypted passwords. Two-factor authentication (2FA) is a comparatively secure authentication mechanism that minimizes the damages in the event of large-scale password leaks.
1. What is 2FA Authentication?
Two-factor authentication (2FA) is a security mechanism that requires two distinct forms of identification to verify a user’s identity when logging into an account. The idea is to add an extra layer of protection beyond just a password. When using 2FA, even if an attacker obtains a user’s password, they cannot access the account without the second form of authentication.
The most common types of 2FA are:
- SMS-based 2FA: A one-time password (OTP) is sent via SMS to the user’s registered mobile number. The user must enter this code to complete the login process.
- Authenticator Apps: Apps like Google Authenticator, Authy, or Microsoft Authenticator generate time-based one-time passwords (TOTP). These apps display a new code every 30 seconds, which the user enters during login.
- Email-Based 2FA: Similar to SMS, a code is sent to the user’s email. This is less secure than SMS or app-based options because email accounts may be more vulnerable to attacks. We will discuss this solution in this article.
- Hardware Tokens: Physical devices, such as YubiKey or Google Titan, generate OTPs or use a protocol like FIDO U2F (Universal 2nd Factor). The user plugs in the hardware device or taps it to authenticate.
- Biometric 2FA: This includes fingerprint, facial recognition, or voice recognition, which are typically used as secondary factors on smartphones or other devices.
//IMAGE
Please note that 2FA is not fully tamper-proof. For example, losing the device (e.g., phone) used for 2FA can lock users out, requiring additional steps to recover access. Similarly, SMS-based 2FA is vulnerable to SIM-swapping attacks, where attackers can intercept SMS codes. Also, Email-based 2FA can be compromised because email accounts are vulnerable to attacks.
2. 2FA Authentication with Spring Security
Spring Security doesn’t provide direct, out-of-the-box support for two-factor authentication (2FA) in a single, all-in-one configuration. However, it does offer the necessary tools to implement 2FA in various ways.
Since Spring Security 6.4, Spring supports the basic OTT login functionality with little scope for customizations. Also, the present OTT logic feature is tightly coupled with the rest of the Spring authentication mechanism, and there is no clarity on how to use it with external application clients.
The built-in OTT authentication feature in Spring security could be improved further to support 2FA, so it is worth checking the latest documentation.
3. Designing a 2FA Authentication Feature
Creating REST APIs is a better approach for building 2FA-based authentication combined with JWT tokens. Any client (web, mobile, or third-party) can use the REST APIs to submit the primary credentials (generally a username/password combination), which will send a secured token to a trusted channel such as email or phone.
Further, these clients can use another REST API to verify the token, which will return a JWT token for further accessing the application as an authenticated user.
As part of the solution, we will develop the following APIs to support the 2FA login:
API Endpoint | Description |
---|---|
POST /sign-up | Creates a new user account in the system. |
POST /login | Authenticate the user credentials |
POST /verify-otp | Verifies one-time token and returns JWT corresponding to the user |
PUT /refresh-token | Returns a new access_token |
GET /user | Returns the details of the logged-in user |
DELETE /user | Deletes the user-account |
GET /joke | Returns a funny joke. This is only for testing purposes. |

4. Implementation
Let’s hit the keyboard and write the required component that will take part in the 2FA authentication feature.
4.1. Maven
First thing first. Start by adding the following dependencies to the Spring Boot application. This demo uses Spring Boot 3, which transitively enforces and imports Spring Security 6 into the application.
- spring-boot-starter-security: will import in the necessary spring security dependencies.
- spring-boot-starter-mail: will help in sending the tokens via email.
- io.jsonwebtoken:jjwt: will help create and validate JWT tokens.
- springdoc-openapi-starter-webmvc-ui: generates API documentation and helps quickly verify the functionality.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.3.0-jre</version>
</dependency>
<!-- Other dependencies -->
</dependencies>
4.2. JPA Entity
Once a user signs, we will store its credentials in the database, so we need to create a User entity first. Apart from email/password, we are adding additional fields to support the 2FA:
- isEmailVerified: A boolan flag signifying that user email has been verified after the initial signup. The verification is complete when the user verifies the secret token/code sent to his/her email.
- isActive: A boolean flag to denote that the user has an active account in the application.
- createdAt: Timestamp when the user was activated.
@Entity
@Table(name = "users")
@Data
public class User implements Serializable {
private static final long serialVersionUID = -3492266417550204992L;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "id", nullable = false, unique = true)
private UUID id;
@Column(name = "email_id", nullable = false, unique = true)
private String emailId;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "email_verified", nullable = false)
private boolean isEmailVerified;
@Column(name = "is_active", nullable = false)
private boolean isActive;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
void setUp() {
this.createdAt = LocalDateTime.now(ZoneId.of("+00:00"));
}
}
The User entity will be saved into the database using the UserRepository which is simple JpaRepository implementation with two additional methods for email verification and search.
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByEmailId(final String emailId);
Optional<User> findByEmailId(final String emailId);
}
4.3. Model
After creating the entity, we need to create some DTO objects that will carry the information in requests and responses between the application and the client applications.
Start by adding SignupRequestDto and LoginRequestDto objects. Both have two fields: email and password. We are creating them separately to add more fields to the flows if needed.
@Getter
@Builder
@Jacksonized
public class LoginRequestDto { // Same as SignupRequestDto
@Email
@NotBlank
private final String emailId;
@NotBlank
private final String password;
}
After the signup or login, user will get the secured token in email which he will submit to the token verification screen. This will be done via OtpVerificationRequestDto.
@Getter
@Builder
@Jacksonized
public class OtpVerificationRequestDto {
private final String emailId;
private final Integer oneTimePassword;
private final OtpContext context;
}
After the token verification, the application will return the access token and the refresh token in the response. Let’s call it LoginSuccessDto.
@Getter
@Builder
@Jacksonized
public class LoginSuccessDto {
private final String accessToken;
private final String refreshToken;
}
4.4. OPT Contexts
Next, create an enum that will define all the contexts when a token is sent to the user’s content medium. We are doing it on three occasions: signup, login, or account deletion.
public enum OtpContext {
SIGN_UP, LOGIN, ACCOUNT_DELETION;
}
5. Spring Security Configuration
Let us look at the Spring security configuration, which configures the SecurityFilterChain and does the following:
- CORS and CSRF: Enables CORS and disables CSRF protection for the stateless API.
- Session Management: Sets the session policy to stateless (SessionCreationPolicy.STATELESS) since JWT is used for authentication.
- Route Authorization: Permits requests to endpoints specified in ApiPathExclusion which includes public URLs and Swagger documentation URLs.
- JWT Filter: Adds a custom JwtAuthenticationFilter before Spring’s built-in UsernamePasswordAuthenticationFilter, allowing JWT tokens to be validated for each request.
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfiguration {
private final CustomUserDetailService customUserDetailService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public UserDetailsService userDetailsService() {
return customUserDetailService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(Arrays.stream(ApiPathExclusion.values())
.map(ApiPathExclusion::getPath)
.toArray(String[]::new)).permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
The public URLs have been defined in ApiPathExclusion enum. You can add or remove the paths as per your requirements.
@Getter
@AllArgsConstructor
public enum ApiPathExclusion {
SWAGGER_API_V2_DOCS("/v2/api-docs"),
SWAGGER_RESOURCE_CONFIGURATION("/swagger-resources/configuration/ui"),
SWAGGER_RESOURCES("/swagger-resources"),
SWAGGER_RESOURCES_SECURITY_CONFIGURATION("/swagger-resources/configuration/security"),
SWAGGER_UI_HTML("swagger-ui.html"),
WEBJARS("/webjars/**"),
SWAGGER_UI("/swagger-ui/**"),
SWAGGER_API_V3_DOCS("/v3/api-docs/**"),
SWAGGER_CONFIGURATION("/configuration/**"),
SWAGGER("/swagger*/**"),
HEALTH_CHECK("/health-check"),
ACTUATOR("/actuator/**"),
LOGIN("/login"),
SIGN_UP("/sign-up"),
OTP_VERIFICATION("/verify-otp"),
REFRESH_TOKEN("/refresh-token");
private final String path;
}
6. JWT Authentication Filter
Further, JwtAuthenticationFilter authenticates users based on a JWT (JSON Web Token) found in the Authorization
header of incoming HTTP requests. It performs the following steps:
- Extracts the
Authorization
header from the request and checks if it contains a JWT prefixed with"Bearer "
. - If the JWT exists, it retrieves the user ID from the token.
- It then checks if the SecurityContext is empty, meaning the user hasn’t already been authenticated.
- Loads user details via customUserDetailService and then validates the JWT.
- If the token is valid, it creates a UsernamePasswordAuthenticationToken to represent the authenticated user and sets it in the SecurityContext.
@Component
@AllArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final CustomUserDetailService customUserDetailService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final var authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null) {
if (authorizationHeader.startsWith("Bearer ")) {
final var token = authorizationHeader.substring(7);
final var userId = jwtUtils.extractUserId(token);
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = customUserDetailService.loadUserByUsername(userId.toString());
if (jwtUtils.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(usernamePasswordAuthenticationToken);
}
}
}
}
filterChain.doFilter(request, response);
}
}
The JwtUtils is a common utility class used in several places and provides methods to create, validate and extract information from JWT token.
import com.howtodoinjava.demo.entity.User;
import com.howtodoinjava.demo.security.configuration.properties.JwtConfigurationProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.AllArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@Component
@EnableConfigurationProperties(JwtConfigurationProperties.class)
@AllArgsConstructor
public class JwtUtils {
private final JwtConfigurationProperties jwtConfigurationProperties;
public String extractEmail(final String token) {
return extractClaim(token, Claims::getSubject);
}
public UUID extractUserId(final String token) {
return UUID.fromString((String) extractAllClaims(token).get("user_id"));
}
public Date extractExpiration(final String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(final String token, final Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(final String token) {
String secretKey = jwtConfigurationProperties.getJwt().getSecretKey();
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
public Boolean isTokenExpired(final String token) {
return extractExpiration(token).before(new Date());
}
public String generateAccessToken(final User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("account_creation_timestamp", user.getCreatedAt().toString());
claims.put("user_id", user.getId());
claims.put("email_id", user.getEmailId());
claims.put("email_verified", user.isEmailVerified());
return createToken(claims, user.getEmailId(), TimeUnit.HOURS.toMillis(1));
}
public String generateRefreshToken(final User user) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, user.getEmailId(), TimeUnit.DAYS.toMillis(15));
}
private String createToken(final Map<String, Object> claims, final String subject,
final Long expiration) {
String secretKey = jwtConfigurationProperties.getJwt().getSecretKey();
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(key)
.compact();
}
public Boolean validateToken(final String token, final UserDetails userDetails) {
final String username = extractEmail(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
7. CORS Filter
The CORS filter controls how a server’s resources (like APIs or assets) can be requested from a domain other than the one serving the resources. Browsers enforce the same-origin policy by default, which restricts web pages from requesting from a domain, subdomain, or port different from the one that served the web page.
CORS relaxes these restrictions to permit cross-origin requests and allow different kinds of clients to access the same set of APIs on the server. Feel free to configure the allowed origins and headers.
@Component
@Order(value = Ordered.HIGHEST_PRECEDENCE)
public class CORSFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) servletResponse;
HttpServletRequest request = (HttpServletRequest) servletRequest;
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Expose-Headers", "Authorization, token, last-visit-date");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Origin, X-Requested-With, Content-Type, Accept");
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
} else {
chain.doFilter(servletRequest, servletResponse);
}
}
@Override
public void destroy() {
}
}
8. Send and Validate OTP
We have used Google Guava’s LoadingCache class to implement the OTP functionality. The LoadingCache is a semi-persistent class, i.e., it’s not persistent by default and stores entries in memory. A CacheLoader can specify how to retrieve or generate the value, often from an external data source, when needed.
The LoadingCache class provides useful methods for storing and searching the tokens in the map and automatically invalidates them when they expire.
@Configuration
@AllArgsConstructor
@EnableConfigurationProperties(OneTimePasswordConfigurationProperties.class)
public class OtpCacheBean {
private final OneTimePasswordConfigurationProperties oneTimePasswordConfigurationProperties;
@Bean
public LoadingCache<String, Integer> loadingCache() {
final var expirationMinutes = oneTimePasswordConfigurationProperties.getOtp()
.getExpirationMinutes();
return CacheBuilder.newBuilder()
.expireAfterWrite(expirationMinutes, TimeUnit.MINUTES)
.build(new CacheLoader<>() {
public Integer load(String key) {
return 0;
}
});
}
}
The default expiration time can be stored in a properties file and supply it in runtime.
@Data
@ConfigurationProperties(prefix = "com.howtodoinjava.app")
public class OneTimePasswordConfigurationProperties {
private OTP otp = new OTP();
@Data
public class OTP {
private Integer expirationMinutes;
}
}
com.howtodoinjava.app.otp.expiration-minutes=5
Next, we write the service methods to store and verify OTP tokens.
- createAccount(): stores the user details in a database and sends OTP to the user’s email.
- login(): verifies the user’s credentials and sends OTP to the user’s email.
- sendOtp(): uses the EmailService to send the actual email.
- verifyOtp(): verifies the OTP against the email.
@Slf4j
@Service
@AllArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final LoadingCache<String, Integer> oneTimePasswordCache;
private final EmailService emailService;
private final JwtUtils jwtUtils;
public ResponseEntity<?> createAccount(
// save user...
sendOtp(savedUser, "Verify your account");
return ResponseEntity.ok(getOtpSendMessage());
}
public ResponseEntity<?> login(final LoginRequestDto userLoginRequestDto) {
final User user = userRepository.findByEmailId(userLoginRequestDto.getEmailId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid login credentials"));
if (!passwordEncoder.matches(userLoginRequestDto.getPassword(), user.getPassword())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid login credentials");
}
if (!user.isActive()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not active");
}
sendOtp(user, "2FA: Request to log in to your account");
return ResponseEntity.ok(getOtpSendMessage());
}
public ResponseEntity<LoginSuccessDto> verifyOtp(final OtpVerificationRequestDto otpVerificationRequestDto) {
User user = userRepository.findByEmailId(otpVerificationRequestDto.getEmailId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email-id"));
Integer storedOneTimePassword = null;
try {
storedOneTimePassword = oneTimePasswordCache.get(user.getEmailId());
} catch (ExecutionException e) {
log.error("FAILED TO FETCH PAIR FROM OTP CACHE: ", e);
throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED);
}
if (storedOneTimePassword.equals(otpVerificationRequestDto.getOneTimePassword())) {
if (otpVerificationRequestDto.getContext().equals(OtpContext.SIGN_UP)) {
user.setEmailVerified(true);
user = userRepository.save(user);
return ResponseEntity
.ok(LoginSuccessDto.builder().accessToken(jwtUtils.generateAccessToken(user))
.refreshToken(jwtUtils.generateRefreshToken(user)).build());
} else if (otpVerificationRequestDto.getContext().equals(OtpContext.LOGIN)) {
return ResponseEntity
.ok(LoginSuccessDto.builder().accessToken(jwtUtils.generateAccessToken(user))
.refreshToken(jwtUtils.generateRefreshToken(user)).build());
} else if (otpVerificationRequestDto.getContext().equals(OtpContext.ACCOUNT_DELETION)) {
user.setActive(false);
user = userRepository.save(user);
return ResponseEntity.ok().build();
}
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
}
private void sendOtp(final User user, final String subject) {
oneTimePasswordCache.invalidate(user.getEmailId());
final var otp = new Random().ints(1, 100000, 999999).sum();
oneTimePasswordCache.put(user.getEmailId(), otp);
log.info("OTP sent :: {}", otp);
CompletableFuture.supplyAsync(() -> {
emailService.sendEmail(user.getEmailId(), subject, "OTP: " + otp);
return HttpStatus.OK;
}).thenAccept(status -> {
System.out.println("Email sent successfully with status: " + status);
});
}
private Map<String, String> getOtpSendMessage() {
final var response = new HashMap<String, String>();
response.put("message",
"OTP sent successfully sent to your registered email-address. verify it using /verify-otp endpoint");
return response;
}
// Other methods ...
}
9. Email Service
We have written a very simple email service class with minimal functionality. You can customize it further as per your needs. It uses JavaMailSender API to send emails.
@Service
@AllArgsConstructor
@EnableConfigurationProperties(EmailConfigurationProperties.class)
public class EmailService {
private final JavaMailSender javaMailSender;
private final EmailConfigurationProperties emailConfigurationProperties;
@Async
public void sendEmail(String toMail, String subject, String messageBody) {
final var simpleMailMessage = new SimpleMailMessage();
simpleMailMessage.setFrom(emailConfigurationProperties.getUsername());
simpleMailMessage.setTo(toMail);
simpleMailMessage.setSubject(subject);
simpleMailMessage.setText(messageBody);
javaMailSender.send(simpleMailMessage);
}
}
@Data
@ConfigurationProperties(prefix = "spring.mail")
public class EmailConfigurationProperties {
private String username;
}
The Spring Mail module uses the properties defined in application.properties to connect with the SMPT server. Use your custom SMTP server in production.
spring.mail.host=smtp.gmail.com
[email protected]
spring.mail.password=************
spring.mail.port=587
spring.mail.properties.mail.smtp.starttls.enable=true
10. OpenAI Swagger Annotated REST Controllers
Finally, we will create the REST API controllers that will accept the requests and respond with responses. We have annotated the REST controllers with Swagger annotations. These annotations will generate great-looking API documentation for clients and a quick test facility for everybody.
The AuthenticationController has the methods related to 2FA authentication functionality, i.e., signup, login, and refresh token.
@RestController
@AllArgsConstructor
public class AuthenticationController {
private final UserService userService;
@PostMapping(value = "/sign-up", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(value = HttpStatus.OK)
@Operation(summary = "Creates a user account in the system")
public ResponseEntity<?> userAccountCreationHandler(
@RequestBody(required = true) final SignupRequestDto userAccountCreationRequestDto) {
return userService.createAccount(userAccountCreationRequestDto);
}
@PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(value = HttpStatus.OK)
@Operation(summary = "Endpoint to authenticate users credentials")
public ResponseEntity<?> userLoginHandler(
@RequestBody(required = true) final LoginRequestDto userLoginRequestDto) {
return userService.login(userLoginRequestDto);
}
@PostMapping(value = "/verify-otp", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(value = HttpStatus.OK)
@Operation(summary = "verifies OTP and returns JWT corresponding to the user")
public ResponseEntity<LoginSuccessDto> otpVerificationHandler(
@RequestBody(required = true) final OtpVerificationRequestDto otpVerificationRequestDto) {
return userService.verifyOtp(otpVerificationRequestDto);
}
@PutMapping(value = "/refresh-token", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(value = HttpStatus.OK)
@Operation(summary = "Returns new access_token")
public ResponseEntity<?> tokenRefresherHandler(
@RequestBody(required = true) final TokenRefreshRequestDto tokenRefreshRequestDto) {
return userService.refreshToken(tokenRefreshRequestDto);
}
}
The UserController has methods to retrieve the information of logged in user and delete the user account.
@RestController
@AllArgsConstructor
public class UserController {
private final UserService userService;
private final JwtUtils jwtUtils;
@GetMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(value = HttpStatus.OK)
@Operation(summary = "Returns logged in users account details")
public ResponseEntity<?> loggedInUserDetailsRetrievalHandler(
@Parameter(hidden = true)
@RequestHeader(required = true, name = "Authorization") final String header) {
return userService.getDetails(jwtUtils.extractUserId(header));
}
@DeleteMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(value = HttpStatus.OK)
@Operation(summary = "Deletes a user account")
public ResponseEntity<?> userAccountDeletionHandler(
@Parameter(hidden = true)
@RequestHeader(required = true, name = "Authorization") final String header) {
return userService.deleteAccount(jwtUtils.extractUserId(header));
}
}
11. Demo
Let’s start the application as a Spring boot and test the APIs. Load the API documentation in the browser by navigating to the URL: http://localhost:8080/swagger-ui/index.html

11.1. Signup Flow
Send a request to the API endpoint: /sign-up and verify the response that indicates that an OTP has been sent to the user email.
curl -X 'POST' \
'http://localhost:8080/sign-up' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"emailId": "[email protected]",
"password": "password"
}'
{
"message": "OTP sent successfully sent to your registered email-address. verify it using /verify-otp endpoint"
}
Check your email. There should be an email.

Submit the OTP token via /verify-token API endpoint.
curl -X 'POST' \
'http://localhost:8080/verify-otp' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"emailId": "[email protected]",
"oneTimePassword": 813554,
"context": "SIGN_UP"
}'
{
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJlbWFpbF9pZCI...",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJobYEf..."
}
You can verify the generated token at: https://jwt.io/

11.2. Login Flow
Similar to the signup process, we can use the /login endpoint to verify the user credentials, and it will send an OTP to the email.
curl -X 'POST' \
'http://localhost:8080/login' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"emailId": "[email protected]",
"password": "password"
}'
{
"message": "OTP sent successfully sent to your registered email-address. verify it using /verify-otp endpoint"
}
Copy the received OTP in request and set the context to LOGIN.
curl -X 'POST' \
'http://localhost:8080/verify-otp' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"emailId": "[email protected]",
"oneTimePassword": 323911,
"context": "LOGIN"
}'
{
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJlbWFpbF9...",
"refreshToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJo..."
}
11.3. Test Authentication Success
After we receive the JWT, we can set it in the “Authorize” section of the documentation so it will sent with each ongoing request to the server.

Next, we will access the /joke endpoint, which is a secured endpoint and returns a joke.

we are able to authenticate the API requests using JWT token after successfully completing the 2FA authentination in this Spring security demo.
12. Summary
In this Spring security 2FA (two-factor authentication) example, we learned to implement the REST APIs supporting the 2FA based on OTPs sent to emails. We created APIs that can be used for signup and login flows involving the genrating the OTPs and validating the OTPs.
We also intregated the Jwt token functionality so the 3rd-part API clients can also use the APi without forcing them to use a defined login screen.
Happy Learning !!
Comments