2026/10/16 10:44:41

Node.js短信验证码登录实战:免费短信接入与安全防刷策略

Node.js短信验证码登录实战:免费短信接入与安全防刷策略 做验证码登录这个事本来挺常规的但真到自己上手 Node.js 从零写一遍还是踩了不少坑。尤其是标题里那个“免费短信”里面门道不少——平台送的测试条数到底能不能撑起日常开发验证码存内存还是 Redis接口被脚本刷了怎么办这些东西文档里通常不会讲全。这篇就把完整的实现过程拆开揉碎了说一遍从流程设计、技术选型到核心代码、安全加固再到我实测中遇到的各种报错和排查思路全部分享出来。适合刚接触 Node.js 想做个带短信登录功能的同学也适合后端转前端、想把登录逻辑彻底搞清楚的开发者。你照着捋一遍基本能把这条链路上的每个环节都弄明白。1. 登录功能的整体设计与流程拆解1.1 短信验证码登录到底做了什么很多刚接触的人会以为短信登录就是“填手机号点击发送输入收到的验证码进系统”这么简单。真实的后端设计里这背后至少涉及四段交互前端提交手机号后端先做格式校验和频率检查后端生成六位随机验证码带上过期时间存起来后端调用短信服务商的接口把验证码通过短信发给用户手机用户拿到验证码填回表单后端再拿这个验证码和之前存的比对匹配且未过期就签发登录凭证。这里最容易忽略的是“存储”和“校验”这两个环节。验证码不是发出去就完事了你得在一个地方记住它而且这个记录必须能跟手机号对应上。同时校验动作不是简单的“相等就通过”还要考虑过期、重复尝试、是否被篡改等边界情况。1.2 技术选型为什么是 Node.js Express Redis主流程既然用 Node.js后端框架选 Express 是最稳妥的选择生态成熟、中间件丰富、出问题随便一搜都是答案。比 Koa 少折腾比 NestJS 少套一层抽象对大多数想快速落地的人来说Express 完全够用。存储方案这里我建议分两个阶段开发调试阶段直接用一个内存 Map 存验证码简单直观重启就清空不担心脏数据部署上线阶段换成 Redis设置键的过期时间让旧验证码自动失效还能做发送频率计数性能也远好于数据库查询。你可能想问为什么不用 MySQL验证码本身就是“短命”数据放 MySQL 还得定期手动清理又慢又费劲。Redis 自带的 TTL 过期机制天然就是为这种场景准备的。存储层的选择直接决定后续代码的复杂度这一步想清楚后面能省不少事。短信平台的选择核心看三点是否有免费测试额度、API 签名是否容易申请、文档是否清晰。免费短信不是无限量通常平台会送你几十条到几百条测试额度够你在开发环境联调。真到了生产环境该充值还是得充值但这不妨碍你把整套逻辑跑通。2. 免费短信平台的选择与接入2.1 免费短信的真相测试额度与替代方案“免费短信”这四个字很容易让人误解。我在实际开发中确认过市面上的主流短信服务商免费部分基本都是“测试赠送条数”常见的是送 10 到 100 条不等。这个量级拿来验证接口通不通、跑通登录流程是足够的但如果你指望一直免费发下去那肯定不现实。除了平台赠送额度还有一种开发期常用的替代方案把验证码打到日志里或者直接返回给前端展示。比如写代码时发短信这个动作先走一个 mock 接口网站控制台直接打印验证码这样联调完全不花钱也方便做自动化测试。等到真正要上线了再切换成真实短信服务商。这个方案对省钱和调试非常友好唯一的注意点是千万别忘了切回真实通道。还有一点需要提前搞清楚绝大多数短信平台的签名和模板是要审核的。签名就是短信前面那个【某某应用】模板就是带变量的短信内容。审核一般几小时到一天不等别等到上线当天才去申请。我见过太多项目因为签名没批下来联调阶段全卡在发送环节。2.2 对接短信平台的通用步骤虽然各家平台的 API 长得不完全一样但对接套路其实是通用的。我用一个通用的网关逻辑来说明你替换成自己选的那家即可。第一步注册平台账号完成企业或个人认证然后新建一个应用拿到 AccessKey 和 SecretKey。这两个凭证要放在后端环境变量里绝对不要写进前端代码或传到仓库。第二步申请短信签名和模板。签名一般写产品名模板里用占位符代替验证码内容。比如模板内容可以是“您的验证码是${code}5分钟内有效”。审核通过后你会拿到模板ID。第三步后端封装一个发送函数。内部其实就是用 HTTPS 请求 POST 一条 JSON 到平台的发送接口带上手机号、模板ID、模板参数和签名。平台返回 code 字段表示是否成功。核心请求代码大致是这个样子const axios require(axios); const SMS_CONFIG { accessKey: process.env.SMS_ACCESS_KEY, secretKey: process.env.SMS_SECRET_KEY, signName: process.env.SMS_SIGN_NAME, templateId: process.env.SMS_TEMPLATE_ID }; async function sendSmsCode(phone, code) { const url https://sms.example.com/v1/send.json; const params { accessKey: SMS_CONFIG.accessKey, signName: SMS_CONFIG.signName, templateId: SMS_CONFIG.templateId, mobile: phone, param: JSON.stringify({ code }) }; // 实际平台需要对参数做签名校验这里示意 params.signature generateSignature(params); const resp await axios.post(url, params); if (resp.data.code ! 200) { throw new Error(短信发送失败: ${resp.data.msg}); } return resp.data; }不同平台在签名算法上有差异有的是 MD5、有的是 HMAC-SHA256这个没有通用解照着平台文档实现就行。我提醒一点先把最简单的请求跑通再加签名逻辑分开调试问题更容易定位。上来就一定要全套报错时反而分不清是参数问题还是签名问题。3. Node.js 核心代码实现3.1 验证码的生成与存储验证码看起来就是Math.random()拼个六位数但这个看似简单的操作里也有讲究。直接用Math.random()生成验证码是可以的因为验证码有效期短、猜测成本高并发量也不大安全风险有限。但你最好用无重复数字或不容易混淆的组合避免用户输错。我习惯用生成随机六位数字字符串的函数然后保证在并发下不重复function generateSmsCode() { return String(Math.floor(100000 Math.random() * 900000)); }这个函数返回的是 100000 到 999999 之间的整数正好六位前导零的情况也规避了。你可以先打印几次看看分布一般都不会出问题。存储我用一个封装好的验证码管理器开发期用内存 Map同时接口设计成跟存储实现解耦方便以后换 Redisconst codeStore new Map(); function saveCode(phone, code, ttlSeconds 300) { codeStore.set(phone, { code, expiresAt: Date.now() ttlSeconds * 1000 }); } function getCode(phone) { const record codeStore.get(phone); if (!record) return null; if (Date.now() record.expiresAt) { codeStore.delete(phone); return null; } return record.code; }如果切 Redis只需要把 saveCode 换成set(phone, code, EX, ttlSeconds)getCode 换成get(phone)业务代码完全不用动。这种轻量的接口隔离是我在实际项目里比较推荐的写法成本极低收益却很大。3.2 短信发送接口封装发送接口不能谁都能调首先要做的是给前端暴露一个请求接口由前端触发发送。后端在这个接口里做以下几件关键的事校验手机号格式是不是大陆手机号检查该手机号是否在 60 秒内已经发送过避免恶意循环发送白烧短信费检查当天发送次数是否超限生成验证码存起来调用 sendSmsCode 真正发出短信把“已发送”状态和当前时间记录到存储里。发送接口的代码大致如下const express require(express); const router express.Router(); const SEND_COOLDOWN_MS 60 * 1000; const DAILY_LIMIT 10; router.post(/sms/send, async (req, res) { const { phone } req.body; if (!/^1[3-9]\d{9}$/.test(phone)) { return res.status(400).json({ code: 400, msg: 手机号格式不正确 }); } const lastSent codeStore.get(phone :lastSent); if (lastSent Date.now() - lastSent SEND_COOLDOWN_MS) { return res.status(429).json({ code: 429, msg: 发送太频繁请稍后再试 }); } const today new Date().toISOString().split(T)[0]; const dailyCount await getDailyCount(phone, today); if (dailyCount DAILY_LIMIT) { return res.status(429).json({ code: 429, msg: 今日发送次数已用尽 }); } const code generateSmsCode(); saveCode(phone, code); await sendSmsCode(phone, code); codeStore.set(phone :lastSent, Date.now()); await incrDailyCount(phone, today); res.json({ code: 200, msg: 验证码已发送 }); });这里值得展开的是 60 秒冷却限制。没做过实际项目的人可能觉得“用户发起请求就会收到验证码”天经地义但真实线上环境里这条限制直接决定你的短信资费。一个手机号如果能在几分钟内无限触发几百条测试额度瞬间就没了。除了冷却每天配额、同一 IP 配额也都值得加后面安全部分再细说。3.3 验证码校验与登录逻辑短信发送只是前半场用户拿到验证码之后填回来校验这部分才是登录的核心逻辑。校验接口做的事情必须严格取出用户提交的手机号和验证码从存储里取出之前存的验证码比对两者是否一致如果一致清理掉这个验证码防止重复使用签发一个登录凭证比如 JWT返回给前端存起来。代码如下const jwt require(jsonwebtoken); const JWT_SECRET process.env.JWT_SECRET || your-secret-key; router.post(/sms/verify, (req, res) { const { phone, code } req.body; if (!/^1[3-9]\d{9}$/.test(phone)) { return res.status(400).json({ code: 400, msg: 手机号格式不正确 }); } const storedCode getCode(phone); if (!storedCode || storedCode ! code) { return res.status(400).json({ code: 400, msg: 验证码错误或已过期 }); } codeStore.delete(phone); const token jwt.sign( { phone, loginAt: Date.now() }, JWT_SECRET, { expiresIn: 7d } ); res.json({ code: 200, msg: 登录成功, data: { token, phone } }); });校验失败会清掉验证码吗我的建议是不清但是要做失败次数限制。比如同一个手机号同一个验证码允许错 5 次超过直接作废必须重新获取。这样可以防止有人拿到你的手机号后在没有短信验证码的情况下暴力穷举那 6 位数字。因为 6 位数字总共只有 90 万个组合但没有限制地无限试的话理论上是能试出来的。所以限制尝试次数这件事非常实际。3.4 前端调用示例前端逻辑简单就是两个按钮两个表单。用户点“获取验证码”调发送接口倒计时 60 秒拿到验证码填进输入框点“登录”调校验接口成功后存 token跳转首页。用最朴素的 fetch 来实现async function sendSms() { const phone document.getElementById(phone).value; const resp await fetch(/api/sms/send, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ phone }) }); const data await resp.json(); if (data.code 200) { alert(验证码已发送请查收短信); startCountdown(60); } else { alert(data.msg); } } async function login() { const phone document.getElementById(phone).value; const code document.getElementById(code).value; const resp await fetch(/api/sms/verify, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ phone, code }) }); const data await resp.json(); if (data.code 200) { localStorage.setItem(token, data.data.token); location.href /dashboard; } else { alert(data.msg); } }前端这里需要关注的细节不多唯一要提醒的是倒计时不能只靠前端拦截。很多人以为按钮置灰就够了实际上用户可以绕过前端直接调接口所以真正的频率限制必须在后端做前端只是体验上的配合。4. 安全加固与踩坑实录4.1 防刷操作频率限制、IP限制、黑名单免费额度被刷是个必经的教训。我测试的时候曾因为没加任何限制一下午被脚本刷掉了 100 多条短信。短信验证码登录面向公网暴露在公网就意味着随时可能被脚本扫。加防刷不是可选项而是必须项。第一层单手机号 60 秒冷却已加过。第二层单手机号每天限制 10 次这层也加过。第三层同一 IP 每天限制 30 次。第四层可以用一个简单的计数器记录失败次数达到阈值就拉入黑名单一段时间const ipFailMap new Map(); function failRecord(ip) { const count ipFailMap.get(ip) || 0; ipFailMap.set(ip, count 1); } function isBlocked(ip) { const count ipFailMap.get(ip) || 0; return count 10; }要注意IP 限制对移动网络用户不太友好因为很多人会共享出口 IP一个出口 IP 下有大量用户。所以 IP 的限制阈值应该比手机号宽松一些宁可放宽一点也别误伤正常用户。我实际用下来单 IP 每天 30 到 50 次是比较合适的区间。还有一点短信验证码接口可以加一个简单的图形验证码或滑块验证在发送前让用户先验证这是行业内比较标准的做法。但为了保持登录体验流畅很多小项目会省略这一步自己权衡即可。省略的话至少频率限制必须做扎实。4.2 常见问题速查表我把实际开发中高频出现的报错和排查思路整理成一张速查表对照着解决问题会快很多。现象可能原因排查与解决短信一直发不出去平台返回签名错误签名未审核通过或签名内容与申请不一致检查平台后台签名状态确认参数里填的是签名名称而不是签名ID验证码发送成功但用户收不到号码被运营商拦截测试账号未开通正式发送权限检查平台发送记录换一个号码测试确认账号状态正常接口报触发频率限制冷却时间未到测试时多次连续点击查看后端日志确认限制逻辑命中可以临时调大冷却时间验证验证码明明正确却校验失败存储是多实例部署请求落到了不同实例把验证码存储切换到 Redis不要用进程内 Map接口能通但每天只允许发几条平台免费额度用尽或账号欠费检查平台余额充值或切换备用平台短信内容显示乱码编码问题常见于特殊字符模板参数统一用 UTF-8 编码检查请求头验证码过期时间不准服务器时区和用户时区差异导致统一用时间戳存储展示时再格式化第三条提醒一下多实例部署是内存 Map 的“天坑”。本地单机跑完全没问题一旦上了负载均衡、多个 Node 进程用户 A 请求落在实例 1 存了验证码校验时请求却打到实例 2那当然永远校验失败。这也是我前面说上线换 Redis 的最大原因。4.3 免费额度耗尽后的优雅降级免费额度总有一天会用完这个大家心里要有数。额度耗尽之后接口并不会更聪明地告诉你反而会让你的发送一直报错。此时有两种做法第一在配置里维护一个每日发送量的计数器接近免费额度上限时在后端日志里打警告同时把短信发送开关自动降级为 mock 模式验证码只在日志里输出不再真实调用平台。这个方案适合全内网联调或演示环境。第二接入第二个备选短信平台一个平台欠费或故障时自动切换。很多团队并不想多花钱但只要你的业务量上来了这个保障还是很有价值。备选平台可以只充值少量金额应急不用保持同样的大额度。我还试过一种更省钱的思路用在内部测试环境干脆不做真实发送而是把验证码固定成123456只对白名单手机号开放。这个方案适合小范围试用上线前必须记得关掉否则就相当于给账号开了个后门。5. 实测中的代码级发现与细节补充5.1 异步接口的正确处理Node.js 里短信发送是一个耗时操作通常在 200 毫秒到 1 秒不等。前端点击“获取验证码”时后端不能因为等待短信平台返回就一直阻塞。上面代码里用了 async/await这是最直接的解决方案。但有一点需要特别注意发送失败时之前存的验证码应该直接删掉避免用户拿着一个未成功发送的验证码去登录。可以用 try/catch 包裹发送逻辑发送失败时清理存储并返回错误try { await sendSmsCode(phone, code); } catch (e) { codeStore.delete(phone); return res.status(500).json({ code: 500, msg: 短信发送失败请稍后再试 }); }这段代码看着小但实际价值不小。不写的话发送失败后用户依然能伪造一个验证码来撞校验而且存储里留着无效记录逻辑就处于一种脏状态。从失败状态里恢复得干净这个习惯很重要。5.2 验证码的安全存储与业务隔离验证码本身不是机密但它在登录链路中的角色很敏感。开发时为了方便可能会把验证码直接打到日志里这个在本地没问题上线后一定要把日志级别调整好不能把验证码打到生产日志。否则运维或拿到日志的人都能看到原始验证码。另一个容易被忽略的点是同一个手机号在“发送验证码”和“注册登录”两条业务上最好互相隔离。如果这个手机号已经被注册走的是登录逻辑如果没注册走的是注册逻辑。不要因为验证码相同而混用两套流程这样做容易在后续排查问题时产生混乱。5.3 环境变量的管理短信平台的密钥JWT 的密钥这些都不能写进代码里。我在项目里通常准备两个文件.env.example提交到仓库里面只放变量名不放真实值.env本地保留而被 git 忽略。加载方式可以用 Node.js 自带的process.env配合 dotenv 解析也可以用node --env-file.env直接加载。Maintenance 上不用担心唯一要保证的是提交代码时不要把.env误传上去。这里补充一个真实遇到的事故某次上线后一直报签名错误查了三个小时发现是部署时环境变量没有注入。这个问题很常见因为本地运行有默认值部署平台却可能把变量名改掉了。建议上线前写两分钟的自检脚本打印出短信配置里几个关键字段是否存在。写在最后的一点心得整个项目跑下来我最大的感受是验证码登录这个功能代码量不大但在工程上的边界却不少。它牵扯到防刷策略、存储选型、第三方依赖、密钥管理每个点单独看都不难组合起来就考验综合能力了。尤其“免费短信”这个点其实更像是开发期的缓冲手段真正要可靠地跑在生产环境短信资费是必须考虑的预算项。如果你是自己做小项目我建议按这个顺序推进先写 mock 通道把整个登录流程跑通再把发送接口换成真实短信平台最后把频率限制和防刷策略补上。别一上来就追求一步到位迭代式开发带来的调试便利远比一次写完带来的成就感更踏实。最后再分享一个小技巧测试短信接口时用真实手机号会消耗额度所以可以留一个专用的测试手机号其他测试一律走 mock。等 mock 通道验证完所有流程再拿测试手机号打一两条真短信确认平台链路没问题这样的额度消耗是最省的。你自己动手做一遍肯定能体会到这些细节的价值。