上周做代码评审,看到一个注册接口,前端表单校验写得密密麻麻,正则、长度、错误提示样样齐全,结果后端只判断了“字段是不是为空”。我当场问了一句:“如果有人直接用 Postman 调你这个接口,传一个mobile=123的字符串进来,会发生什么?”对方愣了一下,说:“前端不会这么传啊。”
问题就出在这句话上。数据校验这件事,很多人默认它约等于“前端校验”,但实际上前端校验只是三道防线里的第一道,而且是最脆的一道。这篇文章就围绕“数据校验”这件事展开,讲清楚为什么只做前端校验很危险,三道防线分别是什么、各自怎么落地,以及我实际项目中踩过的坑。适合刚接触后端开发的人,也适合在 Code Review 里需要跟同事解释“为什么不信任前端”的老开发者。
1. 为什么“数据校验”这件事总是被糊弄过去
1.1 前端校验很显眼,后端校验很“麻烦”
先说一个很普遍的现象:产品验收时,测试人员看到的全是前端交互。输入框旁边有小红字提示、格式错了直接标红、手机号多了几位立刻弹提示——这种反馈直观、即时,给人一种“校验做得很好”的感觉。再加上现在前端校验库和组件成熟,一个正则的事,前端工程师顺手就写了,根本不需要后端参与。
而后端校验呢?写起来没那么“好看”。它不在界面上,不产生任何视觉反馈,只是接口层多了一段判断逻辑。如果是老项目,没有引入校验框架的话,还得自己写一套参数检查工具类,再设计统一错误码。很多团队一算成本,就默认“前端校验已经够了”,后端能少写就少写,甚至只靠数据库报错来兜底。
这个习惯背后还有一层认知误区:大家觉得请求一定是自家页面发出来的,前端校验过了,后端就不用再查了。可实际上,接口一旦发布到公网,谁都可以调用,浏览器页面只是众多请求来源中的一个。前端校验是写给用户看的,服务端校验才是写给系统看的,两者根本不在一个层面上。
1.2 只做前端校验,风险到底藏在哪里
很多人觉得“用户不会恶意提交数据”,所以只做前端校验没什么问题。但现实往往是:大部分脏数据不是恶意攻击者制造的,而是用户随手填的、脚本自动产生的、第三方客户端带过来的。更别说真有攻击者盯上时,前端校验形同虚设。
我列几个最常见的绕过场景,你对照一下自己的项目:
- 打开浏览器开发者工具,删掉
required、maxlength之类的属性,再提交表单,前端校验瞬间失效。 - 直接在控制台执行
fetch或axios请求,把任意参数发给后端接口,完全绕过页面逻辑。 - 用 Postman、curl 写脚本,批量调用接口,连页面都不用打开。
- 爬虫程序模拟请求,伪造 User-Agent、设备信息,一次性灌进来几千条非法数据。
- 并发提交时,前端限流基本无效,攻击者可以瞬间打满接口。
这些操作没有一个是高难度的,但如果你只做前端校验,那后端收到的请求就是一匹脱缰野马:邮箱可以是任意字符串、手机号可以是 1 位数字、用户名字段可以塞进去一段<script>标签、密码可以是空字符串。数据一旦入库,轻则业务逻辑报错,重则页面被注入脚本、数据库被灌入垃圾数据,后面清洗起来能让人崩溃。
打个比方,前端校验像小区门口的保安,态度亲切、登记仔细、视觉上很专业。但保安手里没有安检机,也没有门禁闸机,他只能“礼貌地拦一下”,遇到铁了心要闯进去的人,他根本挡不住。真正决定谁能进楼的是安检机和服务端那道门禁系统。
2. 三道防线分别管什么
2.1 第一道防线:前端校验,管体验不管安全
前端校验的核心目标只有一个:提升用户体验。用户在输入手机号时少打一位,页面立刻告诉他“还差一位”;密码不符合强度要求,提交前就给出提示,不用等请求往返一圈再报错。这个功能的本质是“即时反馈”,它服务的是坐在屏幕前的人,不是保护系统的安全。
所以前端校验的适用范围也很清晰:必填项提示、格式正则、长度限制、两次密码一致、按钮防重复提交这类交互层面的校验。现在前端做这些很方便,HTML 自带的required、type="email",或者表单库里的校验规则,都可以很轻松地实现。
但你要时刻记住一个前提:前端代码是公开透明的,任何一个懂点技术的人都能在浏览器里查看、修改、跳过它。前端校验做得再漂亮,也只代表“用户正常操作时我们会拦截明显错误”,它不能证明请求本身合法。甚至前端传回来的“已通过校验”标记,后端也绝对不能信任,那只是用户或脚本随手填进去的一个字段而已。
注意:不要在“必填、格式”这类基础校验之外,把业务规则也交给前端做,比如“是否已同意协议”“用户角色是否可编辑”这些关键判断。这类逻辑前端可以提示,但最终一定要在后端重新校验。
2.2 第二道防线:服务端校验,数据进入系统的“安检口”
服务端校验是数据进入系统的那道“安检口”,也是所有人必须默认的安全边界。任何请求进来,不管它来自自家页面、第三方客户端、爬虫脚本还是恶意攻击,都要先接受服务端代码的检查,校验通过才放行到业务逻辑,校验失败就直接返回错误。
具体校验哪些内容?我的习惯是分四层来看:
- 基础校验:字段是否存在、是否为空、类型对不对、长度是否超标。
- 格式校验:手机号、邮箱、身份证、URL 是否符合正则规则。
- 枚举校验:状态字段是否在合法取值范围内,比如
status只能是active或disabled。 - 业务规则校验:用户是否已存在、是否有权限操作、关联数据是否存在,这些必须查库或调服务确认。
服务端校验的落地方式有很多:Java 系可以用 Bean Validation 注解,Node.js 可以用 express-validator 或 Joi,Python 可以用 Pydantic。不管用什么工具,核心思路是一致的:在接口入口处集中校验,校验失败返回统一结构的错误信息,绝对不要在校验通过前碰任何业务逻辑。
还有一个很重要的原则:服务端校验必须独立实现,不能复用前端那份“用户提交过来”的判断结果。前端的校验规则可以被绕过,所以后端不能假设“前端已经校验过了”。很多人写代码时习惯性地信任前端传值,等出了问题再排查,那时候脏数据早入库了。
2.3 第三道防线:数据库约束,最后一层“地板”
有人可能会问:“服务端校验都做了,还要数据库约束做什么?”原因很简单:代码会有 bug,并发会产生竞争,团队协作会出现疏漏。服务端校验写得再认真,也可能漏掉某一条规则;或者在并发请求下,两个请求同时通过了“用户名是否已存在”的检查,然后同时插入,结果产生重复数据。
数据库约束就是这栋楼最底下那层地板,它保证的是“落库数据必须满足最基本要求”。哪怕服务端代码出了纰漏,哪怕另一个系统绕过接口直接操作数据库,只要表结构里加了约束,脏数据就插不进来。
常用的数据库约束可以分成几类:
NOT NULL:保证字段必须有值。UNIQUE:唯一索引,保证用户名、手机号不重复。PRIMARY KEY/FOREIGN KEY:主键确保单表记录唯一,外键确保表间引用关系合法。CHECK:限制字段值范围或格式。ENUM:MySQL 中用枚举限制字段只能取几个固定值。
这里要特别提醒一句:MySQL 的CHECK约束在 8.0.16 之前其实是“只存不查”的,写了也不会真正生效。PostgreSQL 的CHECK从很早版本就严格执行。如果你团队还在用老版本 MySQL,别以为表里写了CHECK就万事大吉,先确认一下版本和实际执行计划。
3. 实操:给“用户注册”接口加上完整的三道防线
3.1 第一步:前端表单校验怎么写
以注册功能为例,需求很常见:用户名 3~20 位字母数字下划线、手机号是合法大陆手机号、密码至少 8 位且同时包含字母和数字、确认密码必须一致。前端可以这么写:
function RegisterForm() { const [form, setForm] = useState({ username: '', mobile: '', password: '', confirm: '' }); const [errors, setErrors] = useState({}); function validate() { const nextErrors = {}; if (!/^[a-zA-Z0-9_]{3,20}$/.test(form.username)) { nextErrors.username = '用户名需为3-20位字母、数字或下划线'; } if (!/^1[3-9]\d{9}$/.test(form.mobile)) { nextErrors.mobile = '手机号格式不正确'; } if (!/^(?=.*[A-Za-z])(?=.*\d).{8,}$/.test(form.password)) { nextErrors.password = '密码需至少8位,且同时包含字母和数字'; } if (form.password !== form.confirm) { nextErrors.confirm = '两次输入的密码不一致'; } return nextErrors; } function handleSubmit(e) { e.preventDefault(); const nextErrors = validate(); setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { return; } // 走到这里,说明前端校验通过,可以发请求了 // 但要注意:这部分代码对用户完全可见,绕过它很容易 } }这段代码没什么高深之处,但它清晰地体现了前端校验的边界:它服务于“用户正常输入时的体验”,而不是安全屏障。真实填写的用户看到错误提示会修正,但用脚本或开发者工具的人根本不会经过validate函数。
3.2 第二步:服务端校验怎么写
服务端我用 Node.js 加 express-validator 来做示例。前端校验的规则,后端必须独立再写一遍,而且还要多一层“业务规则校验”。
const { body, validationResult } = require('express-validator'); app.post('/api/register', // 用户名:长度和字符范围 body('username') .trim() .isLength({ min: 3, max: 20 }).withMessage('用户名长度需在3-20个字符之间') .matches(/^[a-zA-Z0-9_]+$/).withMessage('用户名只能包含字母、数字、下划线'), // 手机号:格式必须合法 body('mobile') .trim() .matches(/^1[3-9]\d{9}$/).withMessage('手机号格式不正确'), // 密码:最少8位,且包含字母和数字 body('password') .isLength({ min: 8 }).withMessage('密码至少8位') .matches(/^(?=.*[A-Za-z])(?=.*\d).+$/).withMessage('密码必须同时包含字母和数字'), async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ message: '参数校验失败', errors: errors.array() }); } const { username, mobile, password } = req.body; // 业务规则校验:查一下用户名或手机号是否已被注册 const existingUser = await db.query( 'SELECT id FROM users WHERE username = ? OR mobile = ?', [username, mobile] ); if (existingUser.length > 0) { return res.status(409).json({ message: '用户名或手机号已被注册' }); } // 业务逻辑:密码加密、创建用户、返回结果 // ... } );这里的校验顺序是刻意的:先做基础格式判断,再做业务唯一性查询。格式校验不通过时不需要查库,避免了无效的数据库压力。唯一性查询放在格式校验之后,也能减少被批量扫描接口时数据库承受的负担。
你可能会注意到,服务端校验的规则和前端几乎一模一样,这是对的。有人问“这样不是重复写两遍吗?”确实写两遍,但安全面前没有捷径。唯一性校验这种规则是前端做不了的,它必须查数据库才能确认,这也是服务端那道防线独有的价值。
3.3 第三步:数据库约束怎么写
服务端校验再加一层兜底,表结构可以这样设计:
CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(20) NOT NULL, mobile VARCHAR(11) NOT NULL, password VARCHAR(255) NOT NULL, status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username), UNIQUE KEY uk_mobile (mobile), CONSTRAINT chk_username_format CHECK (username REGEXP '^[a-zA-Z0-9_]{3,20}$'), CONSTRAINT chk_mobile_format CHECK (mobile REGEXP '^1[3-9][0-9]{9}$') );逐个解释一下:
username和mobile都加了UNIQUE索引,这是防并发重复注册最关键的一层。服务端即使同时收到两个相同手机号的请求,两个请求都查库发现“不存在”,然后同时插入时,数据库最终只会让一个成功,另一个直接触发唯一索引冲突。status用ENUM限制只能取active或disabled,内部逻辑再粗心也不会出现status='vip'这种值。CHECK约束兜底了用户名字符范围和手机号格式,正常情况下服务端会拦下来,但万一代码有漏网之鱼,数据库也会拒绝落库。- 密码字段用
VARCHAR(255),是因为存储的是哈希值,不是明文,所以数据库层不需要也不该校验明文密码长度。
需要特别提醒:给已有数据的表加约束之前,一定要先做数据清洗,否则存量脏数据会直接导致建表或ALTER TABLE失败。我遇到过有人直接往线上表加UNIQUE索引,结果因为历史数据里有两个一模一样的手机号,迁移直接中止,回滚白折腾一晚上。
3.4 三道防线如何联调验证
代码写完后不要只测“正常流程”,一定要模拟“前端被绕过”的场景,验证后两道防线真的能独立扛住。我一个一个说:
正常浏览器提交:前端校验通过,请求到达服务端,服务端校验通过,数据库插入成功,这是最顺利的路径。
绕过前端,用 Postman 提交非法参数:比如把mobile传成abc,前端完全没参与,服务端应当拦下来返回 400。这是检验服务端校验是否独立生效的基本操作。
绕过前端和服务端,直接往数据库插数据:这种操作一般只有 DBA 或内部脚本会做,但数据库约束会拒绝不合法数据。你可以手动执行一条INSERT语句试试,插入重复手机号或非法username时,MySQL 会直接报错。
模拟并发注册:压测工具同时发两个相同手机号的注册请求,由于两个请求几乎同时到达,服务端查重可能都查不到,最终会有一个请求触发唯一索引冲突,像这样报错:Duplicate entry '13800138000' for key 'uk_mobile'。这个报错虽然丑,但它恰好说明数据库兜底生效了。
我自己联调时还习惯额外测一个场景:用curl直接发送包含<script>alert(1)</script>的用户名,看看服务端会不会拦截,或者会不会存入数据库。如果服务端没有做 XSS 过滤,即使格式校验过了,将来这段脚本渲染在页面上也够喝一壶的。所以服务端不只是校验格式,该做的字符串清理、转义同样不能省。
4. 常见问题与排查技巧实录
4.1 前后端校验规则不一致怎么处理
前后端校验规则不一致是项目里特别常见的坑。我见过一个系统,前端要求密码至少 8 位,后端却只判断password不能为空,用户在前端老老实实改了密码,后端却完全没感觉;反过来另一种情况是,前端只写了个简单的非空判断,后端严格要求长度和字符组合,用户填了123456直接被后端打回,还看不到具体原因,体验很差。
要解决这种双写一致性,最省心的方式是让前后端共用同一份规则定义。最简单的情况是项目是前后端同构,可以抽一个shared模块,里面导出正则、长度限制常量;跨技术栈的项目,可以用 JSON Schema 或自定义的规则文件,前端负责加载并生成校验提示,后端负责实际校验。
我举一个简单规则文件的例子:
{ "username": { "type": "string", "minLength": 3, "maxLength": 20, "pattern": "^[a-zA-Z0-9_]+$" }, "mobile": { "type": "string", "pattern": "^1[3-9]\\d{9}$" }, "password": { "type": "string", "minLength": 8, "pattern": "^(?=.*[A-Za-z])(?=.*\\d).+$" } }前端可以用 ajv 或 resolver 根据这个文件生成表单校验规则,后端可以用同一份 Schema 做强校验。规则文件统一之后,前端后端的“各自为政”问题才算真正解决。
4.2 数据库约束冲突导致接口报 500
加了数据库约束后,另一个问题马上会出现:唯一索引冲突没有被服务端捕获,用户看到的是 500 错误。比如前面说的并发注册场景,两个请求同时提交相同手机号,数据库直接抛异常,服务端没有处理,异常一路冒到最外层,最后返回“服务器内部错误”。
这个问题的排查步骤是:先看接口错误日志,搜Duplicate entry或 MySQL 的ER_DUP_ENTRY;确认是唯一索引冲突后,不要只想着“先查重再插入”,因为并发下查重天然存在时间窗口。正确做法是在插入时捕获数据库错误,把 1062 之类的错误码转换成友好的 409 或提示文案。
try { await db.insert({ username, mobile, passwordHash }); } catch (err) { if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ message: '用户名或手机号已被注册' }); } throw err; }这里面的经验是:服务端查重和数据库唯一约束不是替代关系,是配合关系。查重代码能拦住大部分重复请求,减少无意义的写入尝试;而数据库约束是最后一道保险,一旦被触发就要转换成用户可以理解的提示,而不是直接抛 500。
4.3 老接口没有校验,从哪里开始补
很多项目不是从零开始的,老接口当初图快没写校验,现在让你去补,最怕的就是改动太大搞崩线上。我的建议是分清优先级,逐步加码,不要梦想一夜之间所有接口都变得固若金汤。
第一步先动数据库。把必填字段的NOT NULL、唯一字段的UNIQUE先加上,这是收益最大、代码改动最小的动作。但这一步之前必须做存量数据清洗,把已有的重复数据、空数据处理掉,不然迁移根本跑不过去。
第二步在统一入口处加基础校验。如果你有网关层或中间件层,先在这里统一做请求体大小限制、Content-Type 检查、必填参数白名单。这些规则比较粗,但能挡住大量低级垃圾请求。
第三步按接口逐个补。优先级从高到低:注册登录、支付交易、文件上传、内容发布这类接口排在最前面。补校验时还要注意,不要为了校验而校验,先审查这个接口的字段到底哪些需要约束,避免把合法业务也拦住。
4.4 攻击者绕过前端直接调接口怎么办
这个问题本质上是问:既然前端可以被轻松绕过,那系统的安全到底靠什么?答案就是服务端校验、鉴权、限流、风控这些后端层面的能力。
服务端参数校验是第一环,它决定“数据合不合法”。但光有参数校验还不够:接口要有身份认证,确认请求者是登录用户;要有鉴权,确认用户有权限执行这个操作;要有频率限制,防止一个 IP 短时间内提交大量请求;要有风控规则,识别异常的设备和行为模式。HTTPS 保证传输过程中数据不被窃听或篡改,但 HTTP 明文传输时,中间人可以直接改写请求内容。
还有一个经常被忽略的原则:服务端不要犯“攻其一点,不及其余”的错误。比如你只校验了注册接口,登录接口却没做防暴力破解,攻击者照样能从登录接口入手尝试撞库。安全的本质是每一道门都要上有质量的锁,而不是把某一扇门封死,其他门却敞开着。
5. 我的实操心得与建议
5.1 三层校验的职责边界,用一句话说清
我在不同团队里讲过很多次,最后总结成一句话:
前端管怎么填,服务端管能不能进,数据库管存不存得下。
这个“三句话”模型对应了三个完全不同的阶段:
| 防线 | 所在位置 | 核心目标 | 失效时会发生什么 |
|---|---|---|---|
| 第一道:前端校验 | 浏览器 / 客户端 | 提升体验、即时反馈 | 用户和脚本可以随意提交脏数据 |
| 第二道:服务端校验 | 后端接口入口 | 拦住非法、恶意、越权请求 | 脏数据进入业务逻辑,甚至入库 |
| 第三道:数据库约束 | 数据库表结构 | 兜底保证数据完整性 | 代码bug和并发问题最终污染数据 |
三层之间不能互相替代,也不能缺位。前端做得再精致,也只是把“错误输入”挡在用户这一侧;服务端是真正的安全边界;数据库是退回底线的那堵墙。任何一个环节缺失,最后出问题的都是数据。
5.2 校验规则共享,从“复制粘贴”到“单一来源”
前面提到了 JSON Schema 的思路,再展开聊聊我的实际经验。早期我在一个前后端分离项目里,前端的正则和后端的正则各写一份,结果项目上线后隔三差五出现“前端能过,后端不能过”或“后端能过,前端不能过”的情况,每次都得两边改代码,非常折磨人。
后来我把所有校验规则抽到了一起,比如 monorepo 里放一个validation包,前端从里面引入规则生成表单校验,后端从同一个包引入规则做参数校验。如果是跨技术栈,就维护一份 JSON Schema,前端用 ajv 之类的库校验,后端用对应的 schema 校验库。改规则时只改一处,然后前后端同步发版,一致性立刻解决。
这里要注意版本管理:规则文件一旦被多个端使用,不能“只改前端不升级后端”,否则版本割裂又会出现新的不一致。发布新规则前最好在当前版本里同时验证前端校验和服务端校验结果是否一致。
5.3 日志、限流与监控,校验之外的补充防线
校验失败的请求不要简单地返回一个 400 就当无事发生,它们其实是重要的安全信号。我习惯把所有校验失败都记到结构化日志里,至少包含请求路径、来源 IP、时间、失败原因、关键参数摘要,但绝对不能记密码、token 等敏感字段。
有了日志之后,还要做两件事。一是限流,针对单个 IP 或单个用户的高频失败请求,果断降低接口访问频率或直接封禁一段时间。二是监控,如果某个接口的“参数校验失败”数量短时间内暴增,大概率是有攻击者在扫接口或者抓包重放,这时候要能通过告警第一时间发现。
校验最好做成一个漏斗:前端负责拦掉正常用户的误操作,服务端负责拦掉非法请求和恶意输入,数据库再兜住所有漏网的数据。日志和监控是漏斗之外的一套“仪表盘”,让你能看清每一层到底拦住了什么、还有没有漏网之鱼。系统上线时间越长,这套仪表盘的价值越明显。
我参与过的早期项目就是只靠前端校验,后来运营同学导出注册用户数据,看到一堆乱填的邮箱、莫名奇妙的用户名、甚至有些字段存了很长的恶意字符串。虽然当时没造成安全事故,但数据已经脏了,清洗起来极其痛苦。后来把服务端校验和数据库约束补全,类似问题基本绝迹。现在习惯已经刻进骨子里了:前端体验归前端,后端安全归后端,数据库兜底归数据库。这个习惯,值得每个开发者早点养成。