Token必须在GET表单页路由中生成并存入会话或Redis,POST提交时仅校验并原子性销毁;绑定用户会话、命名一致、避免竞态、失败可重试是关键。

Token生成时机必须在GET路由里
表单页加载时才生成Token,不是在提交接口(POST)里生成。否则用户刷新页面后Token没变,但服务端已销毁,导致“明明刚打开页面却提示token无效”。Gin中典型写法是:GET /order/form 路由里调用uuid.New().String(),存入session或redis,再通过c.HTML()把Token塞进模板。
常见错误:把Token生成逻辑放在POST /order/submit里——这会让每次提交都生成新Token,校验永远失败。
- Token必须绑定用户会话(如用
gin-contrib/sessions),不能全局共享 - 若用Redis存储,key建议为
form_token:{user_id}:{timestamp},避免不同用户冲突 - 过期时间设为10分钟足够,太长增加被重放风险,太短影响弱网用户操作
校验和销毁必须原子执行
Token验证通过后,必须立刻删除或标记为已使用,且该操作不能被并发请求绕过。Gin本身不提供原子锁,所以不能只靠redis.Get() + redis.Del()两步——中间可能有另一个请求也读到了同一个Token。
正确做法是用Redis的EVAL脚本或SET key value EX 60 NX这类原子命令。例如:
if redis.Exists("form_token:123") == 1 && redis.Get("form_token:123") == "abc123" {
redis.Del("form_token:123")
return true
} else {
return false
}
实际应改用Lua脚本保证原子性,否则高并发下仍可能重复提交。
- 别在
if里先Get再Del,这是经典竞态漏洞 - Session方案更简单,但仅限单机部署;分布式必须用Redis
- 校验失败时返回
400 Bad Request并带明确错误信息,如"token expired or used"
前端隐藏字段名要与后端参数名严格一致
Gin用c.PostForm("token")取值,那HTML里<input type="hidden" name="token" value="xxx">的name属性就必须是token。大小写、下划线、拼写差一个字符都会导致c.PostForm()返回空字符串,校验直接失败。
容易踩的坑是前后端约定不统一:比如前端传csrf_token,后端却用c.PostForm("token")去取;或者模板渲染时变量名写错,像{{ .Token }}漏了首字母大写,导致HTML里value为空。
- 建议统一用
token,避免引入额外命名差异 - 调试时用
fmt.Printf("got token: %s\n", c.PostForm("token"))确认是否取到 - 不要依赖
c.DefaultPostForm()设默认值,它会掩盖真实缺失问题
失败后要不要重发Token?得看场景
如果业务处理失败(比如库存不足),Token已被删,用户刷新页面再提交就会报错。这时候不能简单要求用户重新打开表单页——几十个字段重填体验极差。
可行解法是:业务失败时,把原Token重新写回Redis(或Session),并返回409 Conflict和提示“操作未完成,请重试”。前端捕获该状态后,不跳转、不清空表单,只恢复按钮状态。
- 千万不能在业务异常时跳转到新表单页,那等于强制用户重来
- 重发Token需检查是否已存在,避免覆盖其他有效Token
- 这种“失败可重试”设计比单纯防重更重要,否则防重机制反而成了用户体验瓶颈


















