How to Build a Math Image Captcha with Spring Boot 3.5.5
This step‑by‑step tutorial shows Java beginners how to create a Spring Boot 3.5.5 application that generates a math‑expression image captcha, stores the answer in session or an in‑memory map, provides REST endpoints, handles CORS, and includes a complete front‑end page with refresh and validation logic.
Introduction
Hello! In this article we share a practical feature: Spring Boot image captcha with a simple arithmetic expression. The captcha prevents malicious attacks such as brute‑force or vote‑spamming. Users must solve the expression to pass verification.
Technology stack: Spring Boot 3.5.5 + Java 17 + Thymeleaf + Maven
Feature Preview
Generate random math expression (e.g., 5 + 3 = ?)
Render the expression as an image
User inputs the result
Validate the answer
Support captcha refresh
Environment Preparation
1. Create Spring Boot project
Use Spring Initializr with the following dependencies:
Spring Web
Thymeleaf
Spring Data Redis
Spring Session Data Redis
2. Project Structure
src/
├── main/
│ ├── java/
│ │ └── com/example/demo/
│ │ ├── Demo5Application.java
│ │ ├── config/CorsConfig.java
│ │ ├── controller/CaptchaController.java
│ │ ├── service/CaptchaService.java
│ │ ├── service/MemoryCaptchaService.java
│ │ ├── util/CaptchaUtil.java
│ └── resources/
│ ├── application.properties
│ └── templates/index.html
└── test/java/com/example/demo/CaptchaUtilTest.javaStep 1: Configure Maven Dependencies
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.5</version>
</parent>
<groupId>org.example</groupId>
<artifactId>demo5</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</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-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Step 2: Captcha Utility Class
package com.example.demo.util;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.Random;
/**
* Captcha utility class for generating arithmetic expression images.
*/
public class CaptchaUtil {
/** Generate random math expression and answer */
public static MathExpression generateMathExpression() {
Random random = new Random();
int a = random.nextInt(10) + 1; // 1‑10
int b = random.nextInt(10) + 1; // 1‑10
String operator;
int result;
if (random.nextBoolean()) {
operator = "+";
result = a + b;
} else {
operator = "-";
if (a < b) {
int temp = a;
a = b;
b = temp;
}
result = a - b;
}
String expression = a + " " + operator + " " + b + " = ?";
return new MathExpression(expression, result);
}
/** Generate captcha image as Base64 string */
public static String generateCaptchaImage(String expression) throws IOException {
int width = 120;
int height = 40;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, width, height);
g.setFont(new Font("Arial", Font.BOLD, 16));
Random random = new Random();
for (int i = 0; i < 5; i++) {
int x1 = random.nextInt(width);
int y1 = random.nextInt(height);
int x2 = random.nextInt(width);
int y2 = random.nextInt(height);
g.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
g.drawLine(x1, y1, x2, y2);
}
g.setColor(Color.BLACK);
g.drawString(expression, 10, 25);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
byte[] bytes = baos.toByteArray();
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes);
}
/** Internal class to hold expression and result */
public static class MathExpression {
private String expression;
private int result;
public MathExpression(String expression, int result) {
this.expression = expression;
this.result = result;
}
public String getExpression() { return expression; }
public int getResult() { return result; }
}
}Step 3: Captcha Service Classes
Two implementations are provided.
3.1 Session‑based Service
package com.example.demo.service;
import com.example.demo.util.CaptchaUtil;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
/** Captcha service using HttpSession */
@Service
public class CaptchaService {
public String generateCaptcha() throws IOException {
CaptchaUtil.MathExpression mathExpression = CaptchaUtil.generateMathExpression();
String imageBase64 = CaptchaUtil.generateCaptchaImage(mathExpression.getExpression());
ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpSession session = attr.getRequest().getSession();
session.setAttribute("captchaAnswer", mathExpression.getResult());
session.setMaxInactiveInterval(300); // 5 minutes
return imageBase64;
}
public boolean validateCaptcha(String userAnswer) {
try {
int answer = Integer.parseInt(userAnswer);
ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
HttpSession session = attr.getRequest().getSession();
Integer correctAnswer = (Integer) session.getAttribute("captchaAnswer");
if (correctAnswer != null && answer == correctAnswer) {
session.removeAttribute("captchaAnswer");
return true;
}
return false;
} catch (NumberFormatException e) {
return false;
}
}
}3.2 In‑Memory Service (Cross‑Domain Friendly)
package com.example.demo.service;
import com.example.demo.util.CaptchaUtil;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** Captcha service storing answers in memory */
@Service
public class MemoryCaptchaService {
private final Map<String, Integer> captchaStorage = new ConcurrentHashMap<>();
public CaptchaResponse generateCaptcha() throws IOException {
CaptchaUtil.MathExpression mathExpression = CaptchaUtil.generateMathExpression();
String imageBase64 = CaptchaUtil.generateCaptchaImage(mathExpression.getExpression());
String captchaId = UUID.randomUUID().toString();
captchaStorage.put(captchaId, mathExpression.getResult());
return new CaptchaResponse(captchaId, imageBase64);
}
public boolean validateCaptcha(String captchaId, String userAnswer) {
try {
int answer = Integer.parseInt(userAnswer);
Integer correctAnswer = captchaStorage.get(captchaId);
if (correctAnswer != null && answer == correctAnswer) {
captchaStorage.remove(captchaId);
return true;
}
return false;
} catch (NumberFormatException e) {
return false;
}
}
public static class CaptchaResponse {
private String captchaId;
private String imageBase64;
public CaptchaResponse(String captchaId, String imageBase64) {
this.captchaId = captchaId;
this.imageBase64 = imageBase64;
}
public String getCaptchaId() { return captchaId; }
public String getImageBase64() { return imageBase64; }
}
}Step 4: Captcha Controller
package com.example.demo.controller;
import com.example.demo.service.CaptchaService;
import com.example.demo.service.MemoryCaptchaService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/** Controller handling captcha requests */
@Controller
public class CaptchaController {
@Autowired
private CaptchaService captchaService;
@Autowired
private MemoryCaptchaService memoryCaptchaService;
@GetMapping("/")
public String index(Model model) {
return "index";
}
@GetMapping("/captcha")
@ResponseBody
public ResponseEntity<String> getCaptcha() {
try {
String imageBase64 = captchaService.generateCaptcha();
return ResponseEntity.ok(imageBase64);
} catch (IOException e) {
return ResponseEntity.status(500).body("Failed to generate captcha");
}
}
@PostMapping("/validate")
@ResponseBody
public ResponseEntity<String> validateCaptcha(@RequestParam String answer) {
boolean isValid = captchaService.validateCaptcha(answer);
return isValid ? ResponseEntity.ok("Validation succeeded")
: ResponseEntity.badRequest().body("Validation failed");
}
@GetMapping("/memory-captcha")
@ResponseBody
public ResponseEntity<Map<String, String>> getMemoryCaptcha() {
try {
MemoryCaptchaService.CaptchaResponse response = memoryCaptchaService.generateCaptcha();
Map<String, String> result = new HashMap<>();
result.put("captchaId", response.getCaptchaId());
result.put("imageData", response.getImageBase64());
return ResponseEntity.ok(result);
} catch (IOException e) {
return ResponseEntity.status(500).build();
}
}
@PostMapping("/memory-validate")
@ResponseBody
public ResponseEntity<String> validateMemoryCaptcha(@RequestParam String captchaId, @RequestParam String answer) {
boolean isValid = memoryCaptchaService.validateCaptcha(captchaId, answer);
return isValid ? ResponseEntity.ok("Validation succeeded")
: ResponseEntity.badRequest().body("Validation failed");
}
}Step 5: Front‑End Page (Thymeleaf)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Captcha Demo</title>
<style>
body {font-family: Arial, sans-serif; margin: 50px; background-color: #f5f5f5;}
.container {max-width: 400px; margin: auto; padding: 20px; background: #fff; border-radius: 5px; box-shadow: 0 0 10px rgba(0,0,0,0.1);}
.form-group {margin-bottom: 15px;}
label {display: block; margin-bottom: 5px;}
input[type="text"] {width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;}
button {padding: 10px 15px; background: #4CAF50; color: #fff; border: none; border-radius: 4px; cursor: pointer;}
button:hover {background: #45a049;}
#refreshCaptcha {margin-left: 10px; background: #2196F3;}
#refreshCaptcha:hover {background: #1976D2;}
.message {margin-top: 15px; padding: 10px; border-radius: 4px; display: none;}
.success {background: #dff0d8; color: #3c763d; border: 1px solid #d6e9c6;}
.error {background: #f2dede; color: #a94442; border: 1px solid #ebccd1;}
.captcha-container {display: flex; align-items: center; margin-bottom: 10px;}
</style>
</head>
<body>
<div class="container">
<h2>Captcha Verification</h2>
<div class="form-group">
<label>Captcha:</label>
<div class="captcha-container">
<img id="captchaImage" src="" alt="captcha">
<button id="refreshCaptcha">Refresh</button>
</div>
</div>
<div class="form-group">
<label for="answer">Enter Result:</label>
<input type="text" id="answer" placeholder="Enter result">
</div>
<input type="hidden" id="captchaId" value="">
<button id="submitBtn">Submit</button>
<div id="message" class="message"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const captchaImage = document.getElementById('captchaImage');
const refreshBtn = document.getElementById('refreshCaptcha');
const answerInput = document.getElementById('answer');
const submitBtn = document.getElementById('submitBtn');
const messageDiv = document.getElementById('message');
const captchaIdInput = document.getElementById('captchaId');
function loadCaptcha() {
fetch('http://localhost:8080/memory-captcha')
.then(r => { if (!r.ok) throw new Error('Network error'); return r.json(); })
.then(data => {
captchaImage.src = data.imageData;
captchaIdInput.value = data.captchaId;
})
.catch(err => showMessage('Failed to load captcha', 'error'));
}
loadCaptcha();
refreshBtn.addEventListener('click', function() {
loadCaptcha();
answerInput.value = '';
messageDiv.style.display = 'none';
});
submitBtn.addEventListener('click', function() {
const answer = answerInput.value.trim();
const captchaId = captchaIdInput.value;
if (!answer) return showMessage('Please enter the result', 'error');
if (!captchaId) return showMessage('Captcha expired, refresh', 'error');
const formData = new FormData();
formData.append('captchaId', captchaId);
formData.append('answer', answer);
fetch('http://localhost:8080/memory-validate', {method: 'POST', body: formData})
.then(r => { if (!r.ok) throw new Error('Validation failed'); return r.text(); })
.then(() => showMessage('Validation succeeded!', 'success'))
.catch(() => {
showMessage('Validation failed, try again', 'error');
loadCaptcha();
answerInput.value = '';
});
});
function showMessage(message, type) {
messageDiv.textContent = message;
messageDiv.className = 'message ' + type;
messageDiv.style.display = 'block';
}
});
</script>
</body>
</html>Step 6: CORS Configuration (Cross‑Domain)
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET","POST","PUT","DELETE","OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOriginPattern("*");
configuration.addAllowedMethod("*");
configuration.addAllowedHeader("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}Step 7: Application Properties
spring.application.name=demo5
server.port=8080
server.servlet.session.timeout=300s
# Redis configuration (optional)
# spring.redis.host=localhost
# spring.redis.port=6379
# spring.session.store-type=redis
logging.level.com.example.demo=DEBUGStep 8: Unit Tests
package com.example.demo;
import com.example.demo.util.CaptchaUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** Tests for CaptchaUtil */
public class CaptchaUtilTest {
@Test
public void testGenerateMathExpression() {
CaptchaUtil.MathExpression expression = CaptchaUtil.generateMathExpression();
assertNotNull(expression);
assertNotNull(expression.getExpression());
assertTrue(expression.getExpression().contains("="));
assertTrue(expression.getExpression().contains("?"));
assertTrue(expression.getResult() >= 0);
}
@Test
public void testGenerateCaptchaImage() throws Exception {
String imageBase64 = CaptchaUtil.generateCaptchaImage("5 + 3 = ?");
assertNotNull(imageBase64);
assertTrue(imageBase64.startsWith("data:image/png;base64,"));
assertTrue(imageBase64.length() > 100);
}
@Test
public void testMathExpressionClass() {
CaptchaUtil.MathExpression me = new CaptchaUtil.MathExpression("2 + 3 = ?", 5);
assertEquals("2 + 3 = ?", me.getExpression());
assertEquals(5, me.getResult());
}
}Running and Testing
Run the application via IDE (Demo5Application.java) or mvn spring-boot:run.
Open http://localhost:8080 to see the captcha page.
Enter the calculated result and submit; success or error messages will be displayed.
Use the refresh button to get a new captcha.
Common Issues & Solutions
1. HttpSession import error
Spring Boot 3.x uses jakarta.servlet.http.HttpSession instead of javax.servlet.http.HttpSession. Update the import accordingly.
2. CORS blocked requests
When the front‑end runs on a different origin, configure the CorsConfig class as shown above or use the memory‑based service to avoid session sharing.
3. Session not shared across domains
Switch to the in‑memory service which stores the answer in a ConcurrentHashMap keyed by a UUID. The front‑end sends the captchaId with the validation request.
4. Maven command not recognized
Install Maven and add it to the system PATH, or use the Maven Wrapper ./mvnw spring-boot:run or run directly from the IDE.
Feature Summary
Arithmetic expression captcha (addition/subtraction) with random numbers.
Image generation using Java AWT, Base64 encoding for easy front‑end display.
Two storage options: HttpSession and thread‑safe in‑memory map.
CORS configuration for cross‑origin requests.
Responsive front‑end with automatic loading, refresh, and validation feedback.
Comprehensive unit tests.
Possible Extensions
Store captcha answers in Redis for distributed environments.
Add more operators (*, /) and increase number range.
Implement expiration cleanup with a scheduled task.
Enhance image noise (dots, arcs) for stronger security.
Learning Takeaways
Spring Boot backend development with REST endpoints.
Image creation and Base64 conversion in Java.
Session management and alternative in‑memory storage.
CORS handling for front‑end/back‑end integration.
Writing unit tests with JUnit 5.
Conclusion
By following this guide you have built a complete Spring Boot image captcha system that can be integrated into real web projects to protect against automated attacks.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Selected Java Interview Questions
A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
