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.

Selected Java Interview Questions
Selected Java Interview Questions
Selected Java Interview Questions
How to Build a Math Image Captcha with Spring Boot 3.5.5

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

Step 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=DEBUG

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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaSpring BootCaptchaWeb Development
Selected Java Interview Questions
Written by

Selected Java Interview Questions

A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.