如何设计接口反爬虫:从 Token 到行为风控的完整方案
爬虫拿数据,服务端拦爬虫,爬虫绕过拦截——这场猫鼠游戏从互联网诞生就没停过。
本文从字节跳动系产品(抖音、Coze 等)的 msToken + a_bogus 机制出发,讲清楚接口反爬虫的完整设计思路,以及如何用 Go 落地实现。
一、为什么单靠鉴权不够
大多数接口都有 OAuth token 或 Cookie 鉴权,但这只解决"是不是登录用户"的问题,解决不了"是不是真人在操作"。
一个自动化脚本完全可以:
- 登录拿到合法 token
- 高频调用接口批量拉数据
- 换 IP 绕过简单的 IP 封禁
所以反爬虫要解决的核心问题是:区分真人浏览器和自动化程序。
二、两层核心机制:msToken 与 a_bogus
字节系的方案把防护拆成两层,配合使用。
2.1 msToken:服务端颁发的会话标识
msToken 本质是一段随机 bytes 的 Base64 编码,由服务端在页面加载时生成并写入 Cookie。
它本身不携带业务信息,只是服务端"认识"这个客户端的凭证。
服务端维护一个 token 状态表,校验时检查:
- token 是否是自己签发的
- 是否过期(通常几分钟到几十分钟)
- 是否和请求的 IP、UA 绑定(防止被别人拿走复用)
Go 实现一个简单的 msToken 签发和校验:
package antibot
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
)
type tokenStore struct {
mu sync.RWMutex
tokens map[string]tokenMeta
}
type tokenMeta struct {
ip string
userAgent string
issuedAt time.Time
ttl time.Duration
}
var store = &tokenStore{tokens: make(map[string]tokenMeta)}
// Issue 生成并存储一个 msToken
func Issue(ip, ua string) string {
b := make([]byte, 64)
rand.Read(b)
token := base64.URLEncoding.EncodeToString(b)
store.mu.Lock()
store.tokens[token] = tokenMeta{
ip: ip,
userAgent: ua,
issuedAt: time.Now(),
ttl: 30 * time.Minute,
}
store.mu.Unlock()
return token
}
// Verify 校验 msToken 合法性
func Verify(token, ip, ua string) bool {
store.mu.RLock()
meta, ok := store.tokens[token]
store.mu.RUnlock()
if !ok {
return false
}
if time.Since(meta.issuedAt) > meta.ttl {
return false
}
// 绑定 IP 和 UA(可按需放宽)
return meta.ip == ip && meta.userAgent == ua
}单机版用 map 够用,生产环境换成 Redis,SETEX 控制过期时间即可。
2.2 a_bogus:客户端生成的请求签名
a_bogus 解决的是另一个问题:即使攻击者拿到了有效的 msToken,能不能直接构造请求?
答案是不能——因为每次请求的参数、时间戳都不同,签名结果也不同,服务端会重新计算并比对。
签名输入通常包括:
signature_input = sort(query_params) + msToken + User-Agent + timestamp + nonceGo 实现服务端的签名校验逻辑:
package antibot
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
const signSecret = "your-server-side-secret"
// BuildSignInput 按约定规则拼接待签名字符串
func BuildSignInput(params url.Values, msToken, ua, timestamp, nonce string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", k, params.Get(k)))
}
paramStr := strings.Join(parts, "&")
return strings.Join([]string{paramStr, msToken, ua, timestamp, nonce}, "|")
}
// Sign 生成签名(服务端预期值)
func Sign(input string) string {
mac := hmac.New(sha256.New, []byte(signSecret))
mac.Write([]byte(input))
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
}
// VerifyABogus 校验请求携带的 a_bogus
func VerifyABogus(params url.Values, msToken, ua, timestamp, nonce, aBogus string) bool {
// 时间戳防重放:只接受 5 分钟以内的请求
ts, err := time.Parse(time.RFC3339, timestamp)
if err != nil || time.Since(ts).Abs() > 5*time.Minute {
return false
}
input := BuildSignInput(params, msToken, ua, timestamp, nonce)
expected := Sign(input)
return hmac.Equal([]byte(expected), []byte(aBogus))
}实际工程里,signSecret 不会硬编码,而是通过配置中心下发,并定期轮转。
三、行为风控:签名通过了还不算完
签名只能保证"请求格式合法",爬虫完全可以把签名算法逆向出来,照样跑。所以还需要第三层:行为风控。
3.1 频率限制
最基础的一层,用滑动窗口限流:
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var rdb *redis.Client
// Allow 判断 key(如 IP 或 uid)在 window 内是否超过 limit 次
func Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
now := time.Now().UnixMilli()
windowStart := now - window.Milliseconds()
redisKey := fmt.Sprintf("rl:%s", key)
pipe := rdb.Pipeline()
// 移除窗口外的旧记录
pipe.ZRemRangeByScore(ctx, redisKey, "0", fmt.Sprintf("%d", windowStart))
// 记录本次请求
pipe.ZAdd(ctx, redisKey, redis.Z{Score: float64(now), Member: now})
// 统计窗口内请求数
countCmd := pipe.ZCard(ctx, redisKey)
// 设置 key 过期
pipe.Expire(ctx, redisKey, window*2)
pipe.Exec(ctx)
return countCmd.Val() <= int64(limit)
}按接口、按用户分别限流,核心接口可以设得很严(比如 1 秒 5 次),通用接口可以宽松一些。
3.2 设备指纹
浏览器环境有很多可以采集的特征:Canvas 渲染结果、WebGL 信息、安装的字体列表、屏幕分辨率等。把这些特征哈希成一个"设备指纹",即使换 IP 也能识别同一台机器。
服务端拿到指纹后的处理逻辑:
// FingerprintScore 根据指纹特征打风险分
// 返回 0-100,越高越可疑
func FingerprintScore(fp Fingerprint) int {
score := 0
// 无头浏览器通常没有 WebGL 渲染器信息
if fp.WebGLRenderer == "" || fp.WebGLRenderer == "unknown" {
score += 30
}
// Canvas 指纹全为空白是 Headless 的典型特征
if fp.CanvasHash == "empty" {
score += 30
}
// 字体数量异常少
if fp.FontCount < 5 {
score += 20
}
// UA 声称是桌面浏览器但没有触摸支持
if fp.Platform == "desktop" && !fp.HasTouch && fp.MaxTouchPoints == 0 {
score += 10
}
return score
}3.3 行为序列分析
真人用浏览器的行为有明显的随机性:鼠标移动不是直线、点击有轻微的坐标偏移、页面停留时间分布接近正态。爬虫的行为往往精确得反常。
把用户的操作序列做成时序特征,送进规则引擎或简单的模型打分,高风险的请求要求做验证码或直接拒绝。
四、把三层组合成中间件
在 Go 的 HTTP 中间件里把上面三层串起来:
package middleware
import (
"net/http"
"net/url"
)
func AntiCrawler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := realIP(r)
ua := r.UserAgent()
// 第一层:频率限制
if !ratelimit.Allow(r.Context(), ip, 30, 60*time.Second) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
// 第二层:msToken 校验
msToken := r.Header.Get("X-Ms-Token")
if msToken == "" {
msToken = r.URL.Query().Get("msToken")
}
if !antibot.Verify(msToken, ip, ua) {
http.Error(w, "invalid token", http.StatusForbidden)
return
}
// 第三层:a_bogus 签名校验
q := r.URL.Query()
aBogus := q.Get("a_bogus")
timestamp := q.Get("_ts")
nonce := q.Get("_nonce")
// 从 query 中移除签名参数本身,再参与验签
verifyParams := make(url.Values)
for k, v := range q {
if k != "a_bogus" && k != "_ts" && k != "_nonce" {
verifyParams[k] = v
}
}
if !antibot.VerifyABogus(verifyParams, msToken, ua, timestamp, nonce, aBogus) {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func realIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
return r.RemoteAddr
}五、客户端签名的密钥保护
服务端的 signSecret 好藏,客户端的签名逻辑就尴尬了——JS 代码终究要发给浏览器,有心人总能逆向。字节系的做法是:
- 混淆 + 定期换版本:算法藏在打包混淆的 bundle 里,每次发版都换,让逆向成本持续很高
- 签名算法依赖运行时环境:在 Node.js 或 Puppeteer 里跑同一段 JS,拿到的 Canvas 指纹和真实浏览器不同,签名结果也不同——服务端可以识别
- 不追求完全不可破解:只要让破解成本高于爬虫收益,攻击者就会放弃
这是反爬虫设计的核心哲学:不是要建造无法被攻破的城墙,而是让翻墙的代价比数据的价值更贵。
常见问题
msToken 和 JWT 有什么区别?
JWT 是自包含的,服务端无需存储就能验证;msToken 是服务端存储的随机标识,必须查询才能验证。msToken 的优势是可以随时吊销,JWT 在过期前无法主动失效。
a_bogus 签名算法泄漏了怎么办?
一旦签名算法被逆向公开,应立即发版更新算法,同时在服务端加强行为风控——爬虫通常会在短时间内大量请求,行为特征很明显,可以用频率 + 设备指纹快速识别。
反爬虫会不会误伤真实用户?
会,而且这是设计时必须权衡的。频率限制太严会影响正常的高频用户,设备指纹对换了设备的用户不友好。通常的做法是分级处理:低风险的先警告或触发验证码,高风险的才直接拦截,保留申诉通道。
小型项目需要这么复杂吗?
不需要。中小项目做好两点就够了:接口鉴权 + 频率限制。复杂的签名方案和行为风控只有在数据价值高、爬虫攻击烈度大的场景下才值得投入。
反爬虫没有银弹,每一层防护都能被绕过,但多层叠加之后综合成本就很高了。在真实的工程里,频率限制 + 简单签名 + 监控报警通常已经能挡住 90% 的爬虫,剩下那 10% 的专业对手再考虑更复杂的方案。
遇到具体的反爬场景或者有什么实现上的疑问,欢迎在评论区留言,一起聊聊。
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/api-anti-crawler-design/
备用原文链接: https://blog.fiveyoboy.com/articles/api-anti-crawler-design/