免登录Token实战:匿名会话、并发签发与登录数据合并
“记录免登录获取token”这件事第一次接到需求时我以为是半天的活结果前后改了三版才收口。免登录获取token说白了就是给还没注册、没登录的访客发一张临时入场券让系统能认得“还是刚才那个人”从而把购物车、草稿、浏览足迹、客服会话这些东西挂在他名下。它解决的不是身份认证问题而是匿名状态下的连续性问题。看起来是个很小的技术点但它牵扯到令牌形态选型、并发签发、防刷限流、匿名转登录的数据接管以及一堆只有在真机上才会暴露的边界情况。这篇内容适合正在做游客模式、匿名会话、免登录表单暂存的前后端同学看也适合已经上线但被“用户一刷新购物车就空了”这类问题困扰的人对照排查。下面我按自己实际落地的顺序把这个东西从头到尾拆一遍。1. 免登录 token 解决的是连续性问题而非身份问题很多人一上来就把免登录 token 和登录态 token 混为一谈用同一套签发逻辑、同一套权限校验最后要么权限放大要么维护成本失控。这两个东西的定位从根上就不一样登录态 token 回答的是“你是谁”免登录 token 回答的是“你还是不是刚才那个”。前者是身份凭证后者是会话粘合剂。1.1 匿名访客真正需要被记住的四类场景把需求摊开来看匿名状态下需要被记住的东西其实就那么几类搞清楚这几类令牌该存什么、存多久、能不能撤销基本就有答案了。第一类是过程性数据。用户在商品详情页选了规格、调了数量、点了加购但还没登录。这类数据的特征是价值随时间快速衰减丢了用户会烦但不会造成资损。典型处理是存 7 天登录后合并。第二类是偏好性数据。比如用户把站点切成了深色模式、选了某个城市、调了列表排序。这类数据几乎不衰减甚至可以永久保留但数据量极小一个键值对就能装下。第三类是风控标识。系统需要知道“这一串请求是不是来自同一个来源”用来做频率限制、异常行为识别。这类标识不需要用户可读也不需要跨设备重点是稳定和不可伪造。第四类是实验分桶。灰度发布、A/B 实验需要一个稳定的随机键来分流如果每次请求都重新随机用户会在不同版本之间反复横跳实验数据直接报废。这类分桶键要求的是“同一访客永远落在同一桶”。把这四类需求对齐之后你会发现它们对令牌的要求并不一致过程性数据要求可撤销、可查询风控标识要求不可伪造、短周期分桶键要求绝对稳定。这就意味着指望用一个万能令牌包打天下是不现实的实际落地时通常是“一个主令牌 若干辅助字段”的组合。1.2 免登录令牌和登录态令牌的边界对照我在项目里画过一张边界表后来每次有新同学加入都直接甩给他看比讲半天管用。这张表的核心是两者的权限范围必须是硬隔离的不能靠“约定”。维度登录态令牌免登录令牌有效期2 小时配合刷新令牌7 天滑动续期可访问数据本人全部业务数据仅匿名会话数据撤销方式服务端黑名单 版本号直接删除对应记录存储位置内存 安全存储本地存储可被脚本读取是否含用户标识含用户 ID仅含匿名 ID校验强度验签 查库 设备校验验签为主跨端一致性需要不需要这张表里最关键的一行是“可访问数据”。免登录令牌在服务端必须被识别为“匿名主体”任何需要用户身份才能访问的接口拿到匿名令牌都应该直接返回未授权而不是返回空数据。这两者的区别很大返回未授权前端能明确知道要走登录流程返回空数据前端会以为用户真的没有数据然后就可能出现“登录后购物车被空数据覆盖”这种事故。1.3 直接复用会话 Cookie 会撞上的三堵墙最省事的做法当然是直接用服务端的会话 Cookie浏览器自动带、自动存前端一行代码不用写。但这个方案在实际项目里几乎必然撞墙。第一堵墙是跨端不通。小程序、原生 App、桌面客户端各有各的存储机制Cookie 在这几个环境里根本不是一回事。你要做的是多端统一那就必须有一个前端能主动读取、主动塞进请求头的令牌。第二堵墙是前端读不到。会话 Cookie 通常是 HttpOnly 的这是安全上的正确设计但代价是前端拿不到它没法在请求头里显式携带也没法做本地判断“我到底有没有身份”。对于需要在前端做路由守卫、条件渲染的场景这就很难受。第三堵墙是横向扩展麻烦。会话 Cookie 天然绑定服务端会话存储多实例部署时要么做会话共享要么做粘性会话前者多一层依赖后者在扩缩容时会把用户打散。相比之下自包含的签名令牌在任何一台机器上都能独立验签扩容时不用考虑状态同步。我的结论是主链路用自包含签名令牌特殊场景比如需要防脚本读取的敏感操作再用 HttpOnly Cookie 做补充。两者不冲突各管一段。2. 令牌形态选型存储票、签名票与指纹票的取舍令牌形态的选择直接决定了后面的架构复杂度。我见过三种主流做法每一种都有人用得很顺也见过用错场景之后维护得痛不欲生的。2.1 服务端存储型临时票可控但要多养一套存储存储型临时票的逻辑很直白服务端生成一个随机串作为键把匿名会话数据存在这个键下面设置过期时间然后把随机串发给客户端。客户端每次请求带上这个串服务端查一次存储就能拿到全部上下文。这种做法的最大优势是完全可控。想撤销删掉键就行。想查某个匿名用户干了什么直接读键。想统计当前有多少活跃匿名会话扫一遍键空间或者维护一个计数。这些操作在签名令牌方案里都做不到或者说做起来很别扭。代价也很明确。一是多了一次存储往返每个请求都要查一次QPS 高的时候这是实打实的延迟。二是存储本身成了单点缓存服务抖一下全站匿名用户集体“失忆”。三是数据量会膨胀匿名会话的创建成本极低稍微被人刷一下就能堆出几百万个键内存哗哗地掉。如果决定走这条路有几个参数我建议提前定好过期时间给 7 天并且做滑动续期每个键的 value 控制在 2KB 以内超出的部分拆到独立的业务表里键里只存引用键名加统一前缀方便批量扫描和清理。还有一个容易忽略的点过期时间要用存储层自带的 TTL不要自己在应用层判断。应用层判断的话过期数据不会自动消失只是逻辑上不可见存储会一直涨。2.2 自包含签名令牌无状态但要小心字段膨胀签名令牌把匿名标识直接编码进令牌本身服务端验签就能拿到内容不需要查存储。这对高并发场景非常友好签发和校验都是纯计算横向扩容时没有任何状态同步负担。令牌内容的设计有几个坑我踩过。第一个坑是字段越加越多。一开始只有匿名 ID 和过期时间后来陆续加了设备指纹、渠道来源、实验分桶、语言偏好最后令牌长度飙到 1.5KB每个请求都要带这么大一个头移动网络下很浪费。我的经验是令牌里只放校验必需的和高频读取的字段其他的一律放服务端需要时再查。第二个坑是把敏感信息塞进载荷。签名令牌的载荷默认是 Base64 编码不是加密任何人拿到都能解开看。所以里面绝对不能放手机号、身份证、精确地理位置这些东西哪怕只是哈希过的也要谨慎。匿名令牌的载荷应该只有匿名 ID、令牌类型标记、签发时间、过期时间、唯一标识、版本号。第三个坑是签名算法选错。对称签名HS256实现简单、性能好但密钥必须在所有验签节点上都有一旦某个节点被攻破全站令牌都能伪造。非对称签名RS256、ES256签发方持有私钥验签方只需要公钥安全性更好代价是签名和验签都慢一些密钥管理也更麻烦。对于匿名令牌这种低价值目标我一般用 HS256 配合足够长的随机密钥密钥长度不低于 32 字节并且做定期轮换。下面是我实际用的签发逻辑Python 写的逻辑直接照搬就能跑import time import uuid import hmac import hashlib import base64 import json SECRET b32字节以上的随机密钥从配置中心读取 TOKEN_TTL 7 * 24 * 3600 CLOCK_SKEW 60 # 容忍 60 秒时钟偏移 def b64url(data: bytes) - str: return base64.urlsafe_b64encode(data).rstrip(b).decode() def issue_guest_token(fingerprint: str ) - str: now int(time.time()) header {alg: HS256, typ: JWT} payload { sub: g_ uuid.uuid4().hex, # 匿名主体ID typ: guest, # 令牌类型硬隔离用 iat: now, exp: now TOKEN_TTL, jti: uuid.uuid4().hex, # 唯一标识做幂等和黑名单 ver: 1, # 令牌版本便于整体失效 } if fingerprint: payload[fp] hashlib.sha256( (fingerprint salt).encode() ).hexdigest()[:16] h b64url(json.dumps(header, separators(,, :)).encode()) p b64url(json.dumps(payload, separators(,, :)).encode()) signing_input f{h}.{p}.encode() sig hmac.new(SECRET, signing_input, hashlib.sha256).digest() return f{h}.{p}.{b64url(sig)} def verify_guest_token(token: str) - dict | None: try: h, p, s token.split(.) except ValueError: return None signing_input f{h}.{p}.encode() expect b64url(hmac.new(SECRET, signing_input, hashlib.sha256).digest()) if not hmac.compare_digest(expect, s): return None payload json.loads(base64.urlsafe_b64decode(p )) if payload.get(typ) ! guest: return None if payload[exp] CLOCK_SKEW int(time.time()): return None return payload这段代码里有两个细节值得单独说。hmac.compare_digest是必须的普通字符串比较会因为提前返回而泄漏时序信息理论上可以被用来逐字节爆破签名。CLOCK_SKEW也是必须的多实例部署时机器之间难免有几十毫秒到几秒的时钟差不留容忍窗口的话会出现“刚签发就被判定过期”的诡异现象我在测试环境被这个坑折磨过一整个下午。2.3 设备指纹只能当辅助信号不能当主身份有一类做法是完全不发令牌纯靠设备指纹识别匿名用户。指纹的构成通常是浏览器 UA、屏幕分辨率、时区、字体列表、Canvas 渲染特征这一堆信息的哈希。这个方案听起来很美——零存储、零令牌分发但实际上非常脆弱。同一台设备用户换个浏览器窗口大小、装个插件、系统更新一次指纹就变了。反过来同一批采购的办公电脑硬件和系统完全一致指纹会撞在一起导致多个用户共享同一个匿名身份。我的处理方式是指纹只作为令牌的辅助校验字段用来判断“这个令牌是不是被搬到别的设备上用了”绝不作为主身份。令牌是主键指纹是加分项。指纹对不上时不直接拒绝而是降低信任等级触发二次确认或者限制敏感操作。这个降级策略比一刀切拒绝要温和得多误伤率也低得多。3. 签发链路拆解从首个请求到令牌落地的完整路径令牌形态定下来之后真正麻烦的是签发时机和并发控制。这一段是我觉得整个方案里最容易出问题、也最少被文档提及的部分。3.1 首个请求的三种状态判定服务端在处理每个请求时对令牌的判定其实有三种状态而不是两种。很多人只写了“有令牌”和“没令牌”两个分支漏掉了第三种结果就是各种诡异 bug。第一种是无令牌。客户端第一次访问请求头里什么都没有。这时候直接签发一个新令牌在响应头里返回同时在响应体里也带一份方便前端在不同环境下取用。第二种是令牌有效。验签通过、未过期、类型匹配。这时候直接用令牌里的匿名 ID 处理业务同时在满足续期条件时刷新有效期。第三种是令牌无效这是最容易被忽略的。无效的原因有很多签名不匹配换了密钥、已过期、类型不对比如把登录令牌传到了匿名接口、格式损坏。这几种情况的处理方式应该不同过期的话如果令牌还能解出内容且未超过宽限期可以基于旧的匿名 ID 签发新令牌这样用户数据不丢签名不对或格式损坏只能签发全新的旧数据自然作废。我见过一种实现是“只要无效就当场签发新的”结果就是密钥轮换那天所有匿名用户的数据全丢客服电话被打爆。加了宽限期逻辑之后这个问题才算解决。3.2 首屏并发请求导致的重复签发这是我踩过最疼的一个坑。首页加载时前端会同时发出七八个接口请求推荐列表、分类、配置、消息数、购物车角标……这些请求几乎同时到达服务端每个都发现“没有令牌”于是每个都签发了一个新令牌。结果就是用户在首页的时候系统里同时存在 8 个匿名身份。购物车角标用的是第 3 个令牌下的数据点进详情页用的是第 5 个加购之后写进了第 6 个。用户看到的现象是“我明明加购了购物车里没有”。这个问题有三种解法我都试过说下各自的适用场景。第一种是前端加锁。在应用启动时先串行发一个“获取令牌”的请求拿到之后再放行其他请求。这个方案最干净但会拖慢首屏多一次 RTT。如果令牌可以提前在服务端渲染时注入那更好连这次 RTT 都省了。第二种是服务端去重。同一来源在极短时间窗口内比如 3 秒的签发请求只真正签发一次其他请求返回同一个令牌。去重的键可以用“客户端 IP 指纹哈希”窗口内第一个请求加分布式锁后续请求等待结果。这个方案的代价是需要一次分布式锁但它对前端完全透明。第三种是签发后合并。允许重复签发但在服务端记录一条“这些匿名 ID 属于同一来源”的关联关系后续读数据时把这些 ID 下的数据合并读取。这个方案最复杂但在已有系统上改造时最平滑。我最后选的是第二种因为前端改动最小而且不依赖前端配合。锁的粒度要控制好只锁“签发”这个动作不锁业务请求否则并发直接掉下来。3.3 防刷限流签发接口是天然的放大器签发接口有个特点它是整个系统里唯一一个不需要任何凭证就能无限调用的写接口。这意味着它天然是攻击者的首选目标。你签发一个令牌要占一块存储、消耗一点计算攻击者调一万次就是一万块存储。限流要分层做我一般设三层层级限流维度阈值参考超限动作第一层单 IP20 次/分钟直接拒绝返回 429第二层单指纹5 次/分钟直接拒绝第三层全局峰值 QPS 的 30%触发验证码或排队阈值不是拍脑袋定的要先看真实分布。正常用户一天最多签发 3 到 5 次换设备、清缓存、长时间未访问所以一分钟 20 次已经非常宽松了。我把这个数字压到 10 次也没收到过投诉。还有一个细节签发接口要独立部署或独立限流不要和主站业务接口共用同一个限流桶。否则被刷的时候签发限流把整个桶打满正常业务请求也被误伤。3.4 懒创建令牌只当钥匙不当仓库有个很容易走偏的设计是令牌一签发就立刻在数据库里建一条用户记录、一条购物车记录、一条收藏记录。看着很规整实际上是灾难。匿名用户的转化率通常很低十个访客里可能只有一个最终下单。提前建记录意味着百分之九十的写入是浪费的而且这些垃圾记录会在数据库里长期堆积影响查询性能也影响运营数据的准确性。我现在的做法是令牌只当钥匙签发时不做任何业务写入只产生一个令牌串。真正的业务数据在第一次有实质操作时才创建加购、收藏、提交表单。这样数据库里只有真正活跃过的匿名用户才有记录数据量和真实活跃度基本对齐。这个策略有个副作用要注意如果业务侧有“统计今日访客数”这种需求就不能靠查业务表了得单独埋点。这一点最好在方案评审时就和其他团队对齐避免上线后被问“为什么今天只有三百个访客”。4. 匿名转正登录瞬间的数据接管策略匿名令牌生命周期里最复杂的一环是“转正”——用户从匿名变成登录用户的那一刻之前挂在匿名身份下的数据要怎么处理。这一段处理不好轻则数据丢失重则串号。4.1 合并操作必须放在一个事务里登录成功之后需要做三件事读匿名数据、写入用户数据、删除匿名数据。这三步必须原子完成否则任何一种中间状态都会出问题。如果只做了前两步没做第三步用户下次用同一个浏览器又变成匿名会看到自己“残留”的匿名数据可能被重复合并。如果做了第一步和第三步但第二步失败数据直接丢了。我的实现是把三步包在一个数据库事务里同时用令牌里的唯一标识做幂等键。同样的令牌只允许合并一次第二次进来直接返回“已处理”。这个幂等键可以存在一张合并记录表里保留 30 天过期后清理。合并策略上分两种要提前跟产品确认清楚覆盖式用户数据优先级更高匿名数据直接丢弃只保留用户数据。适合偏好设置这类场景因为用户登录后的设置是他明确的意图。合并式两边数据取并集冲突时按规则取舍。适合购物车、收藏这类场景因为匿名期间加的东西用户是确实想要的。购物车的合并规则特别容易出问题。同款商品在两边都存在时是数量相加还是取较大值我的选择是数量相加但设置上限因为用户加两次说明他真的想要两个。但如果加完之后超过库存要截断到库存上限并且给用户一个提示。这个提示很关键否则用户会以为系统吞了他的商品。4.2 同一设备切换账号的处理边界比合并更麻烦的情况是同一台设备上A 用户登出B 用户登录中间还夹着一段匿名操作。如果不做隔离B 登录时会把 A 留下的匿名数据、甚至 A 的部分数据合并到自己名下这就直接串号了。我的处理原则是登出时必须彻底清空本地令牌并且服务端把该匿名 ID 标记为不可合并。具体做法是登出时生成一个新的匿名令牌同时把旧的匿名 ID 写进一张“已废弃”表保留 7 天。合并逻辑在读取匿名数据前先查一下这个 ID 是否在废弃表里在的话直接跳过合并。这个检查成本很低但能挡住绝大多数串号问题。还有一个容易漏的场景用户在两个标签页里分别登录了两个账号。这种情况下谁后登录谁覆盖本地令牌先登录的那个账号的令牌就丢了。这个不用刻意处理但要在文档里写清楚避免被当成 bug。4.3 清理策略不清理就一定膨胀匿名数据是一定会膨胀的。哪怕转化率再高也会有大量“点进来看了三秒就走”的访客。这些数据不清理的话一年之后表里百分之九十五都是死数据。清理分两层。第一层是存储层的 TTL缓存里的匿名会话必须靠存储自带的过期机制自动消失不能靠应用层扫。第二层是定时任务针对数据库里的业务数据每天凌晨跑一次删除“创建超过 30 天且从未有后续动作”的匿名记录。清扫任务的参数也很讲究。批量删除时每批控制在 1000 条以内批次之间 sleep 100 毫秒避免长事务锁表。删除操作走从库还是主库要看业务压力但一定要加 limit我见过一个没加 limit 的删除任务直接把主库 IO 打满线上告警连响半小时。5. 安全边界与我在真机上踩过的坑前面讲的都是正向设计这一段讲讲反面的东西。免登录令牌因为“零门槛”是很多安全事故的起点我踩过的坑基本都在这个范围里。5.1 关键参数的取值依据参数不是随便填的每个数字背后都应该有个理由。过期时间 7 天。这个数字是从用户行为数据里推出来的百分之八十的回访发生在 3 天内7 天能覆盖到百分之九十五。再长的话存储成本上升而收益趋近于零而且匿名数据本来就是低价值的放太久没意义。密钥长度 32 字节以上。HS256 的密钥如果短于摘要长度安全性会显著下降。32 字节是底线我一般用 48 字节的随机串。时钟偏移容忍 60 秒。这是权衡的结果太长会削弱过期时间的意义太短会误伤时钟不准的节点。60 秒对绝大多数集群都够用。滑动续期的触发阈值是剩余有效期的三分之一。也就是剩余不到两天半时才续期。为什么不是每次请求都续因为那等于每次请求都要重写令牌并回传响应体凭空多出几百字节还容易触发前端的状态更新导致的重复渲染。5.2 五个我在真机上踩到的坑第一个坑令牌被塞进 URL 参数。有个前端的下载功能图省事把令牌拼在查询串里传给后端。结果令牌进了 Nginx 访问日志、进了监控系统、进了用户浏览器的历史记录。后来改成放在请求头里同时把日志里的令牌字段做了脱敏。这个坑的教训是令牌只走请求头任何情况下都不走 URL、不走 Cookie 明文字段、不写进前端日志。第二个坑匿名令牌能访问登录接口。权限校验写成了“有令牌就放行”结果匿名令牌也能调到一堆本该登录后才能用的接口。修复方式是在中间件里做类型判断把令牌类型和接口要求的身份等级做成两维矩阵不匹配直接拒绝。这个矩阵最好写死在配置里不要让业务代码自己去判断。第三个坑缓存服务故障导致签发雪崩。缓存一挂所有请求都判定为“无令牌”然后全部触发签发签发又要写缓存缓存超时请求堆积最后整个服务被拖死。修复方式是加降级缓存不可用时直接签发自包含令牌不写缓存业务侧接受“匿名数据暂时不可用”这个降级结果。降级的开关要能手动切不能全自动。第四个坑指纹变化导致用户被当成新访客。有个用户反馈“每次重启浏览器购物车就空了”。排查发现是 Canvas 指纹在显卡驱动更新后变了服务端做了严格的指纹校验对不上就废弃旧令牌。改成“指纹对不上时降低信任等级但不废弃令牌”之后问题解决。第五个坑令牌长度超出网关限制。因为载荷里越加越多字段令牌长度超过了网关的请求头长度限制表现为间歇性的 400 错误而且只在特定用户身上出现排查了很久。后来给载荷设了硬性上限超过 512 字节就告警再没出现过。5.3 上线前我会过一遍的检查项这套清单我在每个项目上线前都会走一遍不长但每一条都对应过一次真实事故令牌里没有手机号、身份证、精确坐标等可识别信息载荷解出来之后只能看到随机串和时间戳匿名令牌调登录后接口会被明确拒绝返回码和未携带令牌时不同方便前端区分并发十个请求首次访问最终只产生一个匿名身份密钥轮换时有宽限期轮换当天没有大面积数据丢失签发接口独立限流被刷时不影响主站业务匿名数据有自动过期机制且有过一次全量清理的演练登录合并是幂等的重放同一个令牌不会重复合并登出会清空本地令牌且旧匿名 ID 不会再被合并令牌在日志、监控、错误上报里都做了脱敏缓存故障时有降级路径且降级开关可以手动控制这套东西跑顺之后免登录获取 token 其实就变成了一个很安静的基础设施平时没人会想起它。但它的价值恰恰在这里用户不会因为“忘了登录”而丢掉辛苦挑的东西运营不会因为匿名数据混乱而看不懂报表开发也不会因为一个令牌问题被半夜叫起来。我在几个项目里反复打磨这套方案的过程中最大的体会是匿名身份的设计难点从来不在签发那一步而在于你愿不愿意把它当成一个完整生命周期去对待——从签发、续期、限流到合并、清理、降级每一环都补上这东西才算真的稳。