Spring Boot JWT Authentication with Email OTP Verification
A complete, working guide to sign up, email verification and stateless login for a Spring Boot REST API, using Spring Security 6 and jjwt.
Almost every product I build for clients needs the same thing on day one: users who can sign up, prove they own their email address, and then call a protected API from a web or mobile app. This is the setup I used in PennyWise Nepal, a finance tracker API, cleaned up so you can drop it into your own project.
What we are building
The flow has three endpoints:
- POST /api/auth/register creates a disabled account and emails a 6 digit code.
- POST /api/auth/verify checks the code and enables the account.
- POST /api/auth/login checks the password and returns a signed JWT.
Every other endpoint requires an Authorization: Bearer <token> header. The server keeps no session, so it scales horizontally without sticky sessions or a shared session store.
Project setup
Start from a Spring Boot 3 project with Web, Security, Data JPA, Validation, Java Mail Sender and a database driver (PostgreSQL or MySQL). Then add jjwt for creating and parsing tokens:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Keep secrets out of the code and read them from environment variables:
app:
jwt:
secret: ${JWT_SECRET} # Base64 encoded, at least 256 bits
expiration-ms: 3600000 # 1 hour
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
You can generate a strong secret with openssl rand -base64 32. For Gmail, use an app password rather than your real password.
The user entity
A user starts disabled. We store a hash of the OTP, never the code itself, together with an expiry time.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
private boolean enabled = false;
private String otpHash;
private Instant otpExpiresAt;
// getters and setters
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Generating and emailing the OTP
Use SecureRandom, not Random, so codes cannot be predicted. The code is hashed with the same PasswordEncoder we use for passwords and expires after 10 minutes.
@Service
public class OtpService {
private static final Duration OTP_TTL = Duration.ofMinutes(10);
private final SecureRandom random = new SecureRandom();
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
public OtpService(PasswordEncoder passwordEncoder, JavaMailSender mailSender) {
this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
}
public void issueOtp(User user) {
String otp = String.format("%06d", random.nextInt(1_000_000));
user.setOtpHash(passwordEncoder.encode(otp));
user.setOtpExpiresAt(Instant.now().plus(OTP_TTL));
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(user.getEmail());
message.setSubject("Your verification code");
message.setText("Your code is " + otp + ". It expires in 10 minutes.");
mailSender.send(message);
}
public boolean isValid(User user, String otp) {
return user.getOtpHash() != null
&& user.getOtpExpiresAt().isAfter(Instant.now())
&& passwordEncoder.matches(otp, user.getOtpHash());
}
}
Creating and validating JWTs
jjwt 0.12 has a clean builder API. The token carries the user's email as its subject and is signed with an HMAC SHA key derived from our secret.
@Service
public class JwtService {
private final SecretKey key;
private final long expirationMs;
public JwtService(@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
this.expirationMs = expirationMs;
}
public String generateToken(String email) {
Date now = new Date();
return Jwts.builder()
.subject(email)
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationMs))
.signWith(key)
.compact();
}
/** Returns the email inside a valid token, or throws JwtException. */
public String extractEmail(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
}
parseSignedClaims checks the signature and the expiry for you. A tampered or expired token throws a JwtException, which the filter below treats as "not logged in".
The JWT filter
This filter runs once per request. If it finds a valid bearer token, it loads the user and puts them into the SecurityContext. If not, it does nothing and lets Spring Security reject the request later.
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
try {
String email = jwtService.extractEmail(header.substring(7));
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(email);
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (JwtException | UsernameNotFoundException e) {
// Invalid token: continue as an anonymous request
}
chain.doFilter(request, response);
}
}
Security configuration
We disable CSRF (there are no cookies to protect), make the API stateless, open the auth endpoints and require authentication everywhere else.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
.exceptionHandling(e -> e.authenticationEntryPoint(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
UserDetailsService userDetailsService(UserRepository users) {
return email -> users.findByEmail(email)
.map(u -> org.springframework.security.core.userdetails.User
.withUsername(u.getEmail())
.password(u.getPassword())
.disabled(!u.isEnabled())
.roles("USER")
.build())
.orElseThrow(() -> new UsernameNotFoundException(email));
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Because the UserDetailsService reports unverified users as disabled, Spring Security refuses to log them in with no extra code from us.
Auth endpoints
The service holds the business rules. Registration saves a disabled user and sends the code. Verification enables the account and clears the code so it cannot be reused.
@Service
public class AuthService {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final OtpService otpService;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
// constructor omitted
@Transactional
public void register(String email, String password) {
if (users.findByEmail(email).isPresent()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Email already registered");
}
User user = new User();
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
otpService.issueOtp(user);
users.save(user);
}
@Transactional
public void verify(String email, String otp) {
User user = users.findByEmail(email)
.filter(u -> otpService.isValid(u, otp))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid or expired code"));
user.setEnabled(true);
user.setOtpHash(null);
user.setOtpExpiresAt(null);
}
public String login(String email, String password) {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(email, password));
return jwtService.generateToken(email);
}
}
The controller stays thin and validates input with records:
public record RegisterRequest(@Email @NotBlank String email, @Size(min = 8) String password) {}
public record VerifyRequest(@Email @NotBlank String email, @Pattern(regexp = "\\d{6}") String otp) {}
public record LoginRequest(@Email @NotBlank String email, @NotBlank String password) {}
public record TokenResponse(String token) {}
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
@ResponseStatus(HttpStatus.ACCEPTED)
public void register(@Valid @RequestBody RegisterRequest req) {
authService.register(req.email(), req.password());
}
@PostMapping("/verify")
public void verify(@Valid @RequestBody VerifyRequest req) {
authService.verify(req.email(), req.otp());
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest req) {
return new TokenResponse(authService.login(req.email(), req.password()));
}
}
@RestControllerAdvice
class AuthErrors {
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Map<String, String> onAuthError(AuthenticationException e) {
return Map.of("error", "Invalid credentials or unverified account");
}
}
Testing with curl
# 1. Register, then check your inbox for the code
curl -i -X POST localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"s3cure-pass"}'
# 2. Verify the email
curl -i -X POST localhost:8080/api/auth/verify \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","otp":"482913"}'
# 3. Log in and copy the token
curl -s -X POST localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"s3cure-pass"}'
# 4. Call a protected endpoint
curl -i localhost:8080/api/expenses -H "Authorization: Bearer <token>"
Without a token, step 4 returns 401 Unauthorized. With a valid token it reaches your controller.
Production checklist
- Rate limit the register, verify and login endpoints, and lock an OTP after around 5 wrong attempts. A 6 digit code can be brute forced if you allow unlimited guesses.
- Keep access tokens short lived (15 to 60 minutes) and add refresh tokens if users need to stay logged in longer.
- Serve everything over HTTPS. A bearer token is as good as a password to anyone who intercepts it.
- Send email asynchronously (for example with
@Asyncor a queue) so a slow SMTP server does not slow down registration. - Never log tokens or OTP codes, and store the JWT secret in your platform's secret manager.
- Add a resend endpoint that issues a fresh code and invalidates the old one.
With these pieces in place you have a secure, stateless authentication layer that works the same for a React frontend, a mobile app or another backend service.