做一个图形验证码是每个 Web 开发者迟早都要面对的需求。登录、注册、发帖、秒杀、支付确认几乎只要有用户输入和接口调用的地方就能看到它的影子。SpringBoot 因为起步快、生态好成了很多人实现这个功能的首选框架但“能出图”和“能用得稳”完全是两码事。我见过不少项目上线之后被脚本刷爆接口也见过验证码图片死活刷不出来、或者用户明明输对了却一直报错的尴尬场面。这篇就用 SpringBoot 把图形验证码从生成、存储到校验的完整链路拆开讲一遍包含可直接落地的代码、参数选择背后的逻辑以及我在实际项目中踩过的坑。1. 项目整体设计与方案选型1.1 验证码到底在防什么先把问题想清楚我们做图形验证码不是为了让界面看起来“更安全”而是要拦住那些不想被拦的东西。验证码的核心目标是区分“真人操作”和“自动化脚本”。具体的攻击场景很典型登录接口被暴力撞库攻击者拿着泄露的账号密码批量尝试。注册接口被脚本刷量批量注册垃圾账号。短信验证码接口被恶意调用导致短信费用飙升也就是常说的短信轰炸。投票、秒杀、抢购类接口被机器人抢占资源。论坛、评论区被自动发帖机灌入大量的广告内容。这些场景的共同点是它们都依赖一个可以被自动化调用的接口。图形验证码的作用就是在这些关键接口前面加一道“人机校验”的门槛。因为当前的 OCR 技术虽然能识别简单验证码但识别成本和处理耗时都远高于普通请求攻击者需要额外投入算力和代码一大批低价值脚本就会被直接过滤掉。既然如此在设计验证码方案时就要先明确一个原则验证码不是越复杂越好而是“够用且不影响体验”。如果验证码难到用户自己都看不清那它防住的就不是机器人而是真实用户。1.2 主流实现方案横向对比在 SpringBoot 里做图形验证码常见的路线有三条引入老牌的 Kaptcha、使用 Hutool 的图形验证码工具、以及完全自己手写生成逻辑。我三个方案都用过简单说一下各自的定位。方案上手难度定制灵活性依赖体积适合场景Kaptcha低一般主要靠 properties 配置小但很久没大版本更新快速集成、验证码要求不高的内部系统Hutool 验证码工具低中等可以设置长度、干扰方式中Hutool 依赖整体较大项目里已经用了 Hutool不想重复造轮子自研生成器较高高从图片绘制到校验逻辑完全可控无额外依赖JDK 自带 API 就能画需要定制样式、有安全合规要求、想深入理解原理Kaptcha 的老问题是停止更新很多年了在新的 SpringBoot 版本上容易出现兼容性小毛病而且它的验证码风格比较老旧都是那种粗体字符加噪点的形式跟现代项目的视觉风格不太搭。Hutool 很方便但如果你只是为了一个验证码引入整个 Hutool又会觉得有点笨重。我自己更推荐自研。原因很现实图形验证码的生成原理并不复杂核心就是 Java 2D 绘图 API写一个工具类也就是一两百行的事。自研意味着你可以完全控制字符集、干扰方式、颜色方案、宽高比例、旋转角度甚至把公司的品牌色放进去这在对接客户演示或者过等保、合规审查时特别好用。而且自己掌控生成和校验的逻辑排查问题的时候心里有底不用去翻开源库的源码猜它干了什么。1.3 整体架构与请求链路设计验证码功能虽然小但它牵涉到前后端的交互设计。我给你画一下我推荐的请求链路前端页面加载完成后向后端发起一个 GET 请求/captcha/generate。后端生成一个唯一的验证码标识符captchaId同时在服务端生成图形验证码图片并把正确的答案以captchaId为 key 存入存储介质Redis 或本地内存设置有效期。后端把captchaId和验证码图片的 Base64 字符串返回给前端。前端把 Base64 图片渲染到img标签上展示给用户。用户输入验证码后连同表单数据一起提交到业务接口。后端从请求中取出captchaId和用户输入的验证码文本先校验验证码通过后再执行真正的业务逻辑。这个设计里有两个关键点值得强调。第一验证码图片本身不一定要经过前端文件流直接用 Base64 塞进图片的src属性就够了省掉一次图片请求前后端联调非常省事。第二服务端不能只存“答案”还要存captchaId这个 id 就是后面校验时找到对应答案的钥匙。还有一个容易忽略的设计细节验证码的校验时机。一般来说校验操作应该放在业务接口内部而不是单独开放一个校验接口。如果你单独开一个/captcha/check接口相当于给了攻击者一个绕过入口他完全可以直接调业务接口跳过校验步骤。2. 核心细节解析与关键参数设计2.1 图形绘制背后的绘图原理自研验证码的底层就是 Java 的BufferedImage和Graphics2D。打个比方BufferedImage相当于一张空白的画布Graphics2D就是拿在手里的画笔。你先在画布上设定好背景色然后用画笔写字符、画干扰线、撒噪点最后把这张画布编码成图片流输出。具体流程是这样的先创建指定宽高的BufferedImage类型一般用TYPE_INT_RGB然后获取Graphics2D对象。绘制时先setColor设置背景色再fillRect填充整个画布之后逐字绘制验证码字符最后用drawLine和drawOval画干扰元素。绘制完成之后用ImageIO.write()把BufferedImage写入输出流或者用Base64.getEncoder().encodeToString()转成 Base64 字符串。这里有一个很重要但容易被新手忽略的点每次绘制前都要重新获取Graphics2D对象并且绘制完成后要调用g.dispose()释放图形资源。虽然 JVM 的垃圾回收最终会处理但在高并发场景下不主动释放会拖慢内存回收速度导致频繁 Full GC。2.2 验证码字符集和干扰策略怎么定验证码的字符集选择直接关系到识别率和安全性。我见过不少项目直接用0123456789纯数字图省事但纯数字的验证码太容易被 OCR 工具识别。推荐的组合是去掉容易混淆的字符从大写字母加数字中剔除0、O、1、I、L这类难区分的字符。我常用的字符集是private static final char[] CHAR_ARRAY ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789.toCharArray();长度为 4 到 6 位比较合适。太短容易被暴力枚举太长用户输入麻烦。我一般默认用 4 位在安全要求高的场景比如支付确认会动态调整为 6 位。干扰策略是视觉识别和用户体验的平衡点。常见的干扰元素有三类背景噪点、干扰线、字符旋转或扭曲。噪点是在画布上随机撒一些彩色小点干扰线是画几条横穿字符的曲线字符旋转则是让每个字母有正负 30 度以内的随机倾斜。还可以给字符加随机颜色渐变让 OCR 程序更难提取到清晰的字符轮廓。实际测试下来四类干扰方式不用全上否则用户看半天输不对体验很糟糕。我的经验是字符随机旋转 一到两条干扰线 适量噪点识别难度和用户体验处在一个比较满意的平衡点。我平时会在噪点数量上做控制大约在画布面积的 1% 到 2% 就够了。2.3 存储方案为什么推荐 Redis 而不是 Session验证码的答案存哪里是个需要认真思考的问题。早期项目里最朴素的做法是存 Session前端带 Cookie后端从 Session 里读。这种方式在单机部署、用户量不大的时候能跑但一旦上了负载均衡Session 同步就是噩梦。Redis 方案的优势非常明显第一多实例共享一份数据不会出现用户在 A 机器生成、请求被转发到 B 机器就校验不上的问题。第二Redis 自带过期机制set key value EX 120就可以控制验证码有效期不用自己写定时任务清理。第三校验成功后可以原子地删除 key从根本上防止验证码被重复使用。用 Redis 时redis key 的设计也有讲究。我建议用业务前缀加唯一标识的方式比如captcha:login:a3f2b0c1-xxxx这样不同业务的验证码互不干扰排查问题时用keys captcha:login:*也能快速看到该业务下的验证码分布。captchaId直接用 UUID 就好不需要用自增数字因为验证码标识要避免被猜测和枚举。2.4 校验策略与防重放设计校验这步的细节能拉开一个“能用”和“稳”的差距。最基本的校验逻辑是从 Redis 取出captchaId对应的正确答案与用户输入做忽略大小写的比较。但这里有很多值得雕琢的细节。第一个细节是校验后立即删除 key。不管用户输入的验证码对还是错只要执行了校验就把 Redis 里的记录删掉。这样做的目的是防止同一个验证码被反复尝试。如果校验失败不删除攻击者就能对同一个验证码图片做无限次 OCR 识别变相帮他迭代识别模型。第二个细节是失败次数限制。只设置验证码 2 分钟过期还不够因为攻击者可能在高频调接口。我一般在业务接口上加一个基于 IP 的滑动窗口限流比如 1 分钟内同一个 IP 最多允许 10 次校验请求超过就拒绝并提示“操作过于频繁”。这个逻辑可以用 SpringBoot 拦截器实现也可以用 Redis 的INCR加EXPIRE做一个简单的计数器。第三个细节是区分“验证码错误”和“验证码过期”。这两个场景的前端提示应该不一样。错误了提示用户重新输入过期了就要提示用户刷新验证码。如果只有一个笼统的“验证码不正确”用户会反复输入同一次加载的验证码永远输入不对体验非常差。实现上也不复杂Redis key 不存在就是过期存在但不匹配就是错误。3. 实操过程与核心环节实现3.1 工程准备依赖与项目结构我们先从零搭建一个 SpringBoot 工程。我用的是 SpringBoot 2.7.18这个版本在稳定性和兼容性上都是 2.x 系列的巅峰之选。你只做验证码的话依赖清单非常短dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependency /dependencies如果你用的是 SpringBoot 3.x注意把javax.servlet相关的包全部换成jakarta.servlet这一点在后面的兼容性章节会展开说明。工程结构建议按功能分包不要把所有类都堆在一起com.example.captcha ├── config │ └── RedisConfig.java ├── controller │ └── CaptchaController.java ├── service │ ├── CaptchaService.java │ └── impl │ └── CaptchaServiceImpl.java ├── utils │ └── CaptchaGenerator.java └── vo └── CaptchaVO.java3.2 绘制验证码图片的工具类验证码工具类是核心中的核心。我用了一个静态方法传入验证码文本和图片宽高返回BufferedImage。这里的参数都是经过实测调整的public class CaptchaGenerator { private static final char[] CHAR_ARRAY ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789.toCharArray(); private static final int WIDTH 140; private static final int HEIGHT 44; private static final int FONT_SIZE 28; private static final int NOISE_COUNT 80; private static final int LINE_COUNT 2; public static String generateText(int length) { StringBuilder sb new StringBuilder(); Random random new Random(); for (int i 0; i length; i) { sb.append(CHAR_ARRAY[random.nextInt(CHAR_ARRAY.length)]); } return sb.toString(); } public static BufferedImage generateImage(String text) { BufferedImage image new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); Random random new Random(); g.setColor(new Color(245, 247, 250)); g.fillRect(0, 0, WIDTH, HEIGHT); g.setFont(new Font(Arial, Font.BOLD, FONT_SIZE)); // 绘制验证码字符每个字符独立随机旋转 for (int i 0; i text.length(); i) { g.setColor(new Color(20 random.nextInt(80), 20 random.nextInt(80), 20 random.nextInt(80))); double angle (random.nextInt(60) - 30) * Math.PI / 180; g.rotate(angle, 20 i * 30, HEIGHT / 2 6); g.drawString(String.valueOf(text.charAt(i)), 18 i * 30, HEIGHT / 2 10); g.rotate(-angle, 20 i * 30, HEIGHT / 2 6); } // 绘制干扰线 for (int i 0; i LINE_COUNT; i) { g.setColor(new Color(150 random.nextInt(60), 150 random.nextInt(60), 150 random.nextInt(60))); g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); } // 绘制噪点 for (int i 0; i NOISE_COUNT; i) { g.setColor(new Color(100 random.nextInt(100), 100 random.nextInt(100), 100 random.nextInt(100))); g.drawRect(random.nextInt(WIDTH), random.nextInt(HEIGHT), 1, 1); } g.dispose(); return image; } public static String toBase64(BufferedImage image) { try { ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, png, baos); byte[] bytes baos.toByteArray(); return Base64.getEncoder().encodeToString(bytes); } catch (IOException e) { throw new RuntimeException(验证码图片生成失败, e); } } }这里有几个值得注意的细节。字符位置的横坐标用18 i * 30是因为图片宽度 140px、验证码 4 位时每个字符的绘制区域大约 30px两位数从第 18px 开始可以保证字符居中且不会溢出边界。字符的纵坐标HEIGHT / 2 10是一个经验值结合字体大小 28px 和图片高度 44px可以让字符在垂直方向上处于视觉中心。有人会问为什么确认字符旋转用g.rotate()然后再画完就立刻rotate(-angle)转回去。这是非常关键的一步目的是保证每个字符的旋转互不影响。如果不转回去第二个字符画的时候坐标系还停留在第一个字符的旋转角度上画出来的字符位置和角度全乱了。3.3 Service 层生成与校验的业务逻辑工具类负责画图Service 层负责组织业务逻辑。生成验证码的接口要做的事生成验证码文本生成图片文件存 Redis返回给前端 VO。校验接口要做的事从 Redis 取答案比对删除 key返回校验结果。Service public class CaptchaServiceImpl implements CaptchaService { private static final String CAPTCHA_KEY_PREFIX captcha:; private static final long CAPTCHA_EXPIRE_SECONDS 120; Resource private StringRedisTemplate stringRedisTemplate; Override public CaptchaVO generateCaptcha() { String captchaText CaptchaGenerator.generateText(4); BufferedImage image CaptchaGenerator.generateImage(captchaText); String base64 CaptchaGenerator.toBase64(image); String captchaId UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue().set( CAPTCHA_KEY_PREFIX captchaId, captchaText, CAPTCHA_EXPIRE_SECONDS, TimeUnit.SECONDS ); CaptchaVO vo new CaptchaVO(); vo.setCaptchaId(captchaId); vo.setImgBase64(data:image/png;base64, base64); return vo; } Override public boolean verifyCaptcha(String captchaId, String userInput) { if (StringUtils.isBlank(captchaId) || StringUtils.isBlank(userInput)) { return false; } String key CAPTCHA_KEY_PREFIX captchaId; String correct stringRedisTemplate.opsForValue().get(key); if (StringUtils.isBlank(correct)) { return false; } stringRedisTemplate.delete(key); return correct.equalsIgnoreCase(userInput.trim()); } }我在generateCaptcha里犯过一个很多人都会犯的错一开始把data:image/png;base64,前缀放在 VO 里拼接导致前端拿到图片后一直显示不出来。后来排查才发现前端img.src需要的是完整的 Data URL不能只给纯 Base64 字符串。这个前缀和后缀忘写了前端就会把 Base64 当成一个普通 URL 去请求结果是 404 加控制台报错。校验方法这里我故意把“验证码过期”和“验证码错误”都统一返回 false。如果业务上需要区分可以返回一个枚举值但大多数场景下前端只需要知道“校验没通过”。如果我想在前端提示里区分过期和错误我会额外增加一个查询接口不过一般不建议这么做多一个接口就多一个攻击入口。3.4 Controller 层与 Redis 配置Controller 层就简单多了。生成接口用 GET因为它是幂等的请求不会修改业务数据校验逻辑我放在业务接口里面不单独暴露校验接口这也呼应了前面说的安全设计。RestController RequestMapping(/captcha) public class CaptchaController { Resource private CaptchaService captchaService; GetMapping(/generate) public ResultCaptchaVO generate() { return Result.success(captchaService.generateCaptcha()); } }RedisConfig 负责配置RedisTemplate的序列化器。用StringRedisTemplate的话其实不需要额外配置但如果你们项目统一用RedisTemplate一定要把 key 和 value 的序列化器都设置成StringRedisSerializer。否则你存进去的是字符串取出来的是带着转义符的二进制数据比对永远不相等。这个坑我见过不下三次。Configuration public class RedisConfig { Bean public RedisTemplateString, String redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, String redisTemplate new RedisTemplate(); redisTemplate.setConnectionFactory(factory); StringRedisSerializer stringRedisSerializer new StringRedisSerializer(); redisTemplate.setKeySerializer(stringRedisSerializer); redisTemplate.setValueSerializer(stringRedisSerializer); redisTemplate.setHashKeySerializer(stringRedisSerializer); redisTemplate.setHashValueSerializer(stringRedisSerializer); return redisTemplate; } }3.5 前端联调完整示例后端接口就绪后前端拿到接口返回的captchaId和imgBase64直接把 Base64 赋给img.src就能显示图片。下面是一个完整的前端示例包含获取验证码、刷新验证码、提交校验三个动作。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title登录/title /head body img idcaptchaImg src alt验证码 title点击刷新 onclickloadCaptcha() / input typetext idcaptchaInput placeholder请输入验证码 / button onclicklogin()登录/button script function loadCaptcha() { fetch(/captcha/generate) .then(res res.json()) .then(data { document.getElementById(captchaImg).src data.data.imgBase64; window.captchaId data.data.captchaId; }); } function login() { const body { username: document.getElementById(username).value, password: document.getElementById(password).value, captchaId: window.captchaId, captchaInput: document.getElementById(captchaInput).value }; fetch(/user/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) }).then(res res.json()).then(data { if (data.code ! 200) { loadCaptcha(); } }); } window.onload loadCaptcha; /script /body /html前端这里有个常见问题用户点击图片刷新验证码时如果不小心连续点了两次就会触发两个并发请求后返回的结果可能覆盖前一个导致用户看到的和实际存储的captchaId对不上。解决方式也很简单给loadCaptcha加一个锁标记请求期间重复点击直接忽略。3.6 在登录流程中整合验证码校验最后把验证码和登录接口整合起来。这里的核心思想是验证码校验和业务逻辑在同一个事务里验证码先于密码校验执行。这样做有两个好处一是如果验证码都没过就不会继续走密码比对和用户查询减少数据库压力二是避免了单独暴露验证码校验接口可能带来的绕过风险。PostMapping(/login) public ResultString login(RequestBody LoginRequest loginRequest) { boolean captchaValid captchaService.verifyCaptcha( loginRequest.getCaptchaId(), loginRequest.getCaptchaInput() ); if (!captchaValid) { return Result.error(验证码不正确或已过期请重新输入); } // 这里再执行用户名密码校验逻辑 // 建议在密码校验失败超过一定次数后强制要求重新获取验证码 return Result.success(登录成功); }我给一个建议在密码校验失败的时候不仅前端要刷新验证码后端最好也主动把旧的captchaId对应记录作废而不是等它自然过期。假如密码错误但验证码还在有效期攻击者只需要识别一次验证码就能在剩余有效期内不断尝试密码验证码的防护效果会大打折扣。4. 常见问题与排查技巧实录4.1 验证码图片显示不出来的几种原因图片显示不出来是反馈最多的问题。根据我的排查经验绝大部分原因集中在三处第一Base64 前缀缺失。前端的img.src必须是完整的data:image/png;base64,xxxx如果只传了xxxx浏览器会把它当成相对路径去请求自然 404。检查方法很简单用浏览器开发者工具看img.src的值如果开头不是data:image就是服务端返回格式有问题。第二图片编码后的字符串里有特殊字符。Java 的Base64.getEncoder()生成的字符串是标准的 Base64本身不会携带特殊字符。但如果你用了java.util.Base64和sun.misc.BASE64Encoder混用或者经过了 JSON 序列化被转义就可能出问题。确保整个链路都用标准java.util.Base64。第三接口返回的数据结构不对。前端的data.data.imgBase64和你的 VO 字段名必须一一对应比如字段叫imgBase64前端写成imageBase64自然拿不到值。这种问题要多看接口返回的 JSON 原文。4.2 校验总是失败的排查思路校验失败第一步要确认 Redis 里到底有没有数据。用redis-cli连接到 Redis执行keys captcha:*看 key 是否存在再执行get captcha:xxx查一下 key 对应的 value。这里最容易出现的坑是序列化器不一致导致的乱码用默认的 JdkSerializationRedisSerializer 存进去的字符串存的时候是String取出来可能带着\xAC\xED前缀那字符串比对肯定失败。解决方案就是前面说的统一用StringRedisSerializer。还有一个很低级但很常见的错误前后端captchaId传递丢失。前端发起业务请求时没有把captchaId放进请求体后端拿到的captchaId是 null自然找不到 Redis key。前端联调时先打印请求体确认里面有两个字段再排查后端。4.3 验证码刷新后老验证码还能不能用的业务决策这是一个典型的产品和开发博弈问题。从安全角度说用户点击刷新时旧的验证码应该立即作废。但从用户体验说如果用户填到一半不小心点了图片结果提交时报“验证码已过期”体验很糟糕。我的处理方式是这样的前端点击刷新时先调一个新接口作废旧验证码再加载新验证码。这样彻底杜绝了老验证码残留的问题。如果不想多一次请求也可以在后端生成新验证码时删除同一用户之前的所有验证码。在极少数业务要求宽松的项目里也可以接受旧验证码在有效期内继续可用但这是安全妥协不适合安全要求高的场景。4.4 高并发场景下的性能与存储注意事项验证码接口虽然小但它在登录、注册等场景下访问频率极高尤其是在营销活动或恶意攻击的背景下。我遇到过一次真实事件某系统在做活动时验证码生成接口的 QPS 瞬时飙升到几千图片生成非常吃 CPU 和内存直接把应用服务器拖垮。应对措施有几个方向。第一给验证码生成接口加限流比如用 Redis 做计数器每个 IP 每分钟最多生成 30 次防止恶意刷图片。第二控制 Redis 里验证码 key 的总量通过合理的过期时间和容量监控防止 Redis 内存被打满。第三图片生成过程要避免每次 new 大对象BufferedImage是重量级对象尽量复用宽度、高度、字体等常量减少 GC 压力。4.5 SpringBoot 2.7 升级到 3.x 的兼容性改造代码写完后如果你打算把 SpringBoot 从 2.7 升级到 3.x有两点必须注意。第一原来的javax.servlet包已经换成了jakarta.servlet所有涉及 Servlet API 的导入路径都要改。第二Spring Security 6 的配置方式变化较大如果你用了自定义过滤器做验证码校验原有的WebSecurityConfigurerAdapter已经被废弃需要用SecurityFilterChain的方式重新配置。好消息是我们这个自研验证码方案几乎没有第三方依赖升级时只需要检查一下javax到jakarta的包名替换以及 Redis 客户端连接配置的兼容性。相比那些深度依赖 Kaptcha 的老项目这种自研方案的迁移成本要小很多这也是我坚持自研的原因之一。4.6 常见问题速查表问题现象可能原因解决方案图片显示为空缺少data:image/png;base64,前缀VO 拼接完整 Data URL图片显示为默认图标前端字段名与后端 VO 不一致用开发者工具检查 JSON 结构校验永远失败Redis 序列化器配置错误统一使用 StringRedisSerializer验证码过期太快过期时间设置过短检查 Redis key 的 TTL建议 120 秒同一验证码可重复使用校验后没有删除 key校验后立即执行 delete 操作高频刷验证码导致接口超时缺少限流机制增加 IP 维度的 Redis 计数器限流升级 SpringBoot 3.x 后启动报错javax 包不存在替换为 jakarta 开头的包这个验证码组件后来被我用在了好几个项目里从后台管理系统的登录页到用户端的找回密码流程基本上一次写好到处复用。回看整个方案最核心的收获其实就一句话图形验证码的重点不在“画图”而在“存储与校验的闭环设计”。如果你打算把它做得更完善可以沿着这些方向继续扩展识别难度更高的滑块验证码、行为轨迹验证码、或者是验证码嵌套在短信发送接口前做二次风控。但无论怎么变后端的存储、过期、防重放、限流这套骨架和今天讲的是一模一样的。先把这套骨架跑通后面再加什么都稳。