☰
二维码扫码登录原理与安全实现:状态机、轮询与临时凭证
2026/10/29 8:51:06 网站建设 项目流程

1. 先搞清楚这道题到底在考什么

前几天一个朋友面完某大厂的二面,回来第一句话就是:“问我二维码扫码登录的原理,我讲了两分钟轮询和 token,面试官追问了一句‘那二维码被人截图发出去怎么办’,我当场就卡住了。”这个场景其实特别典型,二维码扫码登录这道题看着简单,几乎每天都在用,但真要把它讲透,从二维码里装了什么、状态怎么流转、客户端怎么拿到登录态,到中间每一环的安全边界,能连续答上三层的人并不多。

我这些年做过几个带扫码登录的系统,也踩过不少坑,比如二维码被恶意替换、轮询把接口打崩、确认之后客户端拿到一个永远换不出去的临时凭证。所以这篇就按面试复盘的思路,把二维码扫码登录这套东西从头到尾拆一遍,既讲原理也讲实现,顺带把面试官最爱追问的几个点提前堵上。不管你是准备面试,还是真要在项目里落地一套扫码登录,下面的内容都能直接拿去用。

先说清楚它解决的是什么问题。用户在电脑浏览器里要登录一个网页,但密码输入麻烦、记不住,或者干脆是用手机号验证码登录又嫌收短信慢。这时候网页上出现一个二维码,手机扫一下、点个确认,电脑就登录了。整个过程用户没有在电脑上输入任何凭证,登录态是从手机“搬”到电脑上的。所以扫码登录的本质不是“扫码”,而是跨设备的信任传递:手机端已经证明过“你是你”,现在要把这个证明安全地转移给另一台设备。理解了这个本质,后面所有的设计取舍都好解释了。

2. 整条链路拆开看:四个角色和三次状态跃迁

2.1 谁在参与,各自负责什么

一套扫码登录至少涉及四个角色:待登录的电脑浏览器、手机客户端、业务服务端、以及存放临时状态的存储层(通常是 Redis 这类带过期能力的缓存)。电脑浏览器负责展示二维码和等待结果,手机客户端负责扫码和确认,服务端负责生成票据、维护状态、最终签发登录凭证,存储层负责让这些临时状态在多个服务实例之间共享。

这里有个容易被忽略的点:手机端扫码登录的前提是手机端本身已经处于登录状态。它不是在验证密码,而是在用已有的登录态做一次授权确认。所以扫码登录严格来说是“已登录设备授权新设备登录”,这个前提决定了它的安全模型——攻击者如果连手机都拿到了,那扫码登录也拦不住,方案设计的目标是防住“二维码泄露”这类中间环节的风险,而不是防住设备本身失守。

我见过有同学在面试里把扫码登录说成“免密登录的一种”,这个表述不算错但不准确。免密登录强调的是不输密码,扫码登录强调的是设备间授权,两者的安全假设完全不同,面试官听到这种混淆通常会继续追问,反而暴露理解深度不够。

2.2 状态机:从“待扫描”到“已确认”

整条链路的核心是一个状态机,通常有五个状态:待扫描、已扫描待确认、已确认、已过期、已取消。用户打开登录页时,服务端生成一个全局唯一的票据编号,状态置为待扫描,同时设置一个较短的有效期,常见是 60 到 120 秒。手机扫码后,客户端解析出票据编号,携带自己的登录态请求服务端把状态改成已扫描,同时在手机屏幕上弹出确认页。用户点确认,状态变成已确认,服务端这时候才生成一个一次性的、短有效期的临时凭证,挂在这个票据下面。电脑端下一次轮询读到已确认,拿到临时凭证,再用它去换正式的会话凭证,登录完成。

这个状态机看起来平平无奇,但每一步都有讲究。比如“已扫描”这个中间态为什么必须存在?因为它是用户体验和安全性的平衡点。如果没有这个状态,手机一扫就直接登录,用户没有反悔的机会,万一扫错了或者被诱导扫码,连拒绝的窗口都没有。有了这个中间态,手机端可以展示“你正在登录某应用”的确认页,用户看清了再点,这是一个很关键的人为确认环节。

再比如“已确认”之后为什么要发临时凭证,而不是直接把正式会话给电脑端?因为轮询通道本身不是可信通道,它是浏览器发出的普通 HTTP 请求,可能被中间人观察、可能被日志记录。临时凭证一次性、几十秒内失效、只能用一次,即使泄露了攻击窗口也极小。这个“短命凭证换长命会话”的模式,在 OAuth 那套授权体系里也是同一个思路。

2.3 为什么二维码里是一串 URL 而不是加密数据

很多人第一次实现的时候会想,二维码里是不是应该塞一段加密过的用户信息?其实不需要,也不应该。标准做法是二维码里只放一个带票据编号的 URL,比如https://example.com/qr/confirm?ticket=xxxxx,手机上扫码后客户端解析这个 URL 拿到 ticket,再发请求给服务端。这么做有三个好处:一是二维码内容极短,编码密度低,扫得快、识别率高,弱光或者屏幕反光的情况下也能扫出来;二是票据本身不承载任何敏感信息,即使二维码被拍照传播,泄露的也只是一个很快就会过期的编号;三是手机端不需要理解复杂的加密格式,解析 URL 取参数就行,兼容性最好。

二维码的容量是有限的,版本越高格子越密,识别难度越大。如果你往里面塞几百字节的加密串,二维码会变得非常密,老年机或者摄像头素质一般的设备扫起来就很吃力。我实测过,同样的内容长度下,内容短的二维码在低端机上首次识别成功率明显更高。所以“二维码里只放一个短 URL”不只是习惯,是实打实的体验优化。

3. 通信机制选型:轮询、长轮询还是长连接

3.1 三种方案的取舍逻辑

电脑端怎么知道手机那边确认了?这是整条链路里最容易被追问的技术选型点。最朴素的做法是轮询:浏览器每隔几秒发一次请求问“我这个票据现在什么状态”。优点是实现简单、兼容性无敌、不依赖任何特殊协议;缺点是请求量大,而且大部分请求是空转。假设同时有 10 万人在登录页等待,每 3 秒轮询一次,那就是每秒三万多请求,这些请求绝大多数只会返回“还没扫”。这个量级对服务端来说不算小,而且全是无效计算。

长轮询的做法是浏览器发一个请求,服务端不立刻返回,而是把连接挂住,等到状态变化或者超时(比如 30 秒)再返回。这样空转请求大幅减少,实时性也更好。但它对服务端的连接管理能力有要求,挂住的连接要占用资源,网关层、负载均衡层对超时时间的限制也各不相同,有的网关默认 60 秒就掐断,你得确认配置。

长连接(WebSocket 或者类似机制)实时性最好,状态一变立刻推送,但引入的复杂度也最高:连接保活、断线重连、多实例下的消息路由,每一项都是工作量。我的经验是,登录场景对实时性的要求其实没那么高,用户点完确认之后多等一秒完全能接受,所以大部分业务用长轮询就够了,没必要上长连接。

3.2 一个实用的降频策略

真正让我省下大量资源的不是换协议,而是一个很简单的前端策略:分阶段调整轮询频率。用户刚打开页面、二维码还没被扫的时候,轮询间隔放长到 4 到 5 秒,因为这个阶段状态几乎不会变;一旦某次轮询返回“已扫描”,前端立刻把间隔缩短到 1 秒甚至更短,因为接下来用户很可能马上点确认,这时候要保证响应快。这个策略不需要改服务端任何逻辑,纯前端调整,实测能把轮询请求量压掉六七成,而用户感知的延迟几乎没变化。

具体算一下:假设平均每个用户在登录页停留 20 秒,其中前 15 秒是待扫描、后 5 秒是已扫描。固定 2 秒轮询的话,平均要发 10 次请求。用分阶段策略,前 15 秒按 5 秒一次发 3 次,后 5 秒按 1 秒一次发 5 次,总共 8 次,而且这 8 次里有 5 次是在关键窗口期发的。如果待扫描阶段用户停留更久(很多人打开页面就去干别的了),节省的比例会更大。这个细节在面试里说出来,面试官一般会觉得你是真做过。

3.3 票据过期与重复登录的处理

票据一定要有有效期,而且过期之后不能简单返回一个“无效”,否则用户看到的是莫名其妙的报错。我的做法是轮询接口在票据过期时返回一个明确的状态码,前端收到后自动刷新一次二维码,同时给一句“二维码已刷新,请重新扫描”的提示。用户几乎无感,体验上比报错好太多。

还有一个坑是重复登录。用户在电脑 A 上扫码登录了,又在电脑 B 上打开登录页,这时候两台设备各自持有不同的票据,互不干扰,这是正常的。但如果同一个票据被两个浏览器同时轮询,那就说明票据泄露了或者页面被复制了。我的处理方式是:票据在生成时就跟浏览器的会话标识绑定,轮询时校验这个绑定关系,不匹配就直接让票据失效并返回错误。这一条能挡住一部分重放攻击,实现成本很低。

4. 安全边界:面试官最爱追问的那几个点

4.1 二维码被截图转发出去怎么办

这是最经典的一问。攻击者在你身后拍了张二维码的照片,或者通过屏幕共享截了图,然后自己先扫,会发生什么?如果没有任何防护,攻击者扫完点了确认,登录的就是他的手机账号,而你在电脑这边看到的是“登录成功”,但登进去的是别人的账号——这是经典的二维码替换/抢先扫描攻击。更糟的情况是攻击者把二维码换成自己的,诱导你扫,你一扫就帮他登录了。

防护的核心思路是让“确认”这个动作在正确的上下文里发生。第一层是短有效期加一次性,票据 60 秒过期,用过即废,攻击窗口很窄。第二层是设备指纹绑定,票据在生成时记录发起登录的浏览器环境特征,轮询时校验,防止票据被搬到别的会话里用。第三层是确认页展示足够的信息,让用户能判断这次登录是不是自己发起的,比如显示登录的设备类型、大致位置、时间。有些产品会在确认时要求输入一次指纹或者做一次本机验证,把攻击成本再抬高一层。

但要说实话,这一类攻击没有一劳永逸的解法,因为它的前提是攻击者已经能接触到你屏幕上的内容。方案能做的是把窗口压小、把成本抬高、把异常暴露出来,而不是彻底消灭。面试的时候如果你能把这个“防护是分层的、没有银弹”的观点讲出来,比背一堆名词更有说服力。

4.2 确认接口的防重放设计

手机端点确认时调用的那个接口,是整条链路里权限最高的一个动作,它直接决定了“把登录态发给谁”。这个接口必须满足三个条件:必须携带手机端有效的登录态,必须携带票据编号,同一票据只能成功确认一次。第三条尤其重要,实现上可以依赖存储层的原子操作,比如把状态从“已扫描”原子地切换到“已确认”,切换失败的请求直接拒绝。这样即使攻击者重放了确认请求,第二次也会因为状态已经变了而失败。

还有一个细节是临时凭证的生成时机。很多实现是在“已确认”那一刻就生成临时凭证并写入存储,等电脑端来取。我倾向于在电脑端来取的时候再生成,并且生成后立刻删除存储里的凭证,保证“取一次就没了”。这样做的好处是凭证在存储里的存活时间从“用户确认到电脑端轮询”这段不确定的间隔,缩短到几乎为零,泄露面更小。代价是电脑端如果网络抖动拿不到,就得重新走一遍流程,所以我会给一个比较宽松的重试窗口,比如凭证生成后 30 秒内允许尝试两次,超时作废。

4.3 其他几个容易被忽略的加固点

第一,轮询接口必须校验浏览器会话。有些人为了实现方便,把轮询接口做成完全公开的,只要知道票据编号就能查状态。这看起来很无害,实际上泄露了“谁在什么时候登录了什么”这类元信息。第二,二维码页面的响应要禁止被缓存,加上合适的缓存控制头,防止被 CDN 或者代理缓存后分发给别人。第三,日志里绝对不能记录完整的票据和临时凭证,最多记录前缀,否则日志系统本身就成了泄露源。第四,如果产品支持多端同时在线,要明确扫码登录产生的新会话对旧会话的影响,是踢掉旧的还是并存,这个策略要提前定好,别等上线了再改。

这几条在面试里不一定会被问到,但如果你主动提出来,说明你考虑的是工程落地而不只是流程本身。我自己的习惯是,每设计一个带临时凭证的流程,都会专门问自己一句:这个凭证如果落到别人手里,能干什么,能撑多久。这个问题问完,加固点基本就出来了。

5. 手写一个最小可运行的原型

5.1 数据模型与存储设计

光讲原理不够,我习惯把核心逻辑写一遍,写完才知道哪里会卡。下面这个原型用 Python 加 Redis 模拟,其他语言照搬思路即可。存储结构很简单,一个 key 对应一个票据,value 用哈希存状态、绑定的会话标识、扫码用户、创建时间。

# 票据结构(Redis Hash) # key: qr:ticket:{ticket_id} # status: WAITING | SCANNED | CONFIRMED | EXPIRED # session_id: 浏览器会话标识,用于绑定 # user_id: 扫码确认的用户 # created_at: 时间戳 QR_TTL = 90 # 票据有效期,秒 TEMP_TTL = 30 # 临时凭证有效期,秒

有效期设 90 秒是我反复试出来的值。太短用户来不及扫,太长又放大泄露窗口。90 秒基本能覆盖“打开页面、掏手机、解锁、打开应用、对准扫码”这一串动作,再短就会有明显的失败率。临时凭证 30 秒足够电脑端轮询一次拿到了,因为轮询间隔最长也就几秒。

5.2 三个核心接口的实现

生成票据的接口在用户打开登录页时调用,返回票据编号和二维码内容。这里注意,票据编号要足够随机,用 UUID 或者更长的随机串,不能自增,否则可以被枚举。

import uuid, time, redis r = redis.Redis() def create_ticket(session_id: str): ticket = uuid.uuid4().hex key = f"qr:ticket:{ticket}" r.hset(key, mapping={ "status": "WAITING", "session_id": session_id, "created_at": int(time.time()), }) r.expire(key, QR_TTL) # 二维码内容只放短 URL return ticket, f"https://example.com/qr/confirm?ticket={ticket}"

手机端扫码后先调“标记已扫描”,再调“确认登录”。标记已扫描这个动作要校验手机端登录态,同时用原子操作保证状态只能从 WAITING 往前走,防止乱序。

def mark_scanned(ticket: str, user_id: str): key = f"qr:ticket:{ticket}" # 只有 WAITING 才能变成 SCANNED current = r.hget(key, "status") if current != b"WAITING": return {"ok": False, "reason": "invalid_status"} r.hset(key, mapping={"status": "SCANNED", "user_id": user_id}) return {"ok": True} def confirm_login(ticket: str, user_id: str): key = f"qr:ticket:{ticket}" current = r.hget(key, "status") if current != b"SCANNED": return {"ok": False, "reason": "invalid_status"} # 生成一次性临时凭证,挂到票据下 temp_token = uuid.uuid4().hex r.hset(key, mapping={"status": "CONFIRMED", "temp_token": temp_token}) r.setex(f"qr:temp:{temp_token}", TEMP_TTL, user_id) return {"ok": True}

电脑端轮询时做两件事:校验会话绑定,读取状态。如果状态是 CONFIRMED,就把临时凭证取出来交给前端,同时把这个凭证从存储里删掉,保证只发一次。

def poll_status(ticket: str, session_id: str): key = f"qr:ticket:{ticket}" data = r.hgetall(key) if not data: return {"status": "EXPIRED"} if data.get(b"session_id", b"").decode() != session_id: # 会话不匹配,视为异常,直接让票据失效 r.delete(key) return {"status": "INVALID"} status = data[b"status"].decode() if status == "CONFIRMED": temp_token = data.get(b"temp_token", b"").decode() if temp_token: r.delete(f"qr:temp:{temp_token}") r.hset(key, "temp_token", "") return {"status": "CONFIRMED", "temp_token": temp_token} return {"status": status}

临时凭证被删掉之后,如果电脑端因为网络问题没收到,下一次轮询拿到的 temp_token 就是空字符串,这时候返回一个明确的状态让前端提示重新扫码。这个失败路径一定要测,不然线上偶发的“确认了但没登进去”会非常难查。

5.3 实测中的几个坑

第一个坑是时区。created_at用本地时间存,跨机房部署之后判断过期就会错乱,统一用时间戳或者服务端时间。第二个坑是 Redis 的过期和状态判断之间的竞态:票据可能在hgetall之后、读状态之前刚好过期,这时候拿到的是空字典,要按过期处理,不能当成未知状态。第三个坑是确认接口的幂等,用户手抖点两下确认,第二次会因为状态已经是 CONFIRMED 而失败,前端要把这个失败吞掉当成成功处理,否则用户会看到无意义的报错。

原型跑通之后我做了一次压测,单机 Redis 下这套逻辑在几千 QPS 的量级毫无压力,真正的瓶颈永远在轮询请求的接入层,而不是状态存储本身。这也印证了前面的判断:优化重点应该放在减少无效轮询上,而不是纠结存储性能。

6. 常见问题速查与排查思路

6.1 典型故障对照表

实际运维中遇到的问题五花八门,我把遇到过的整理成一张表,方便对照排查。

现象可能原因排查方向处理方式
扫码后手机提示成功,电脑一直转圈临时凭证被提前消费或轮询会话不匹配查轮询日志里的状态返回值和会话标识确认轮询接口的会话校验逻辑,检查是否有并发轮询把凭证抢走
二维码刚出来就提示过期服务端时间与前端展示时间不一致,或 TTL 配置过短核对服务器时间、检查 TTL 常量统一用服务端时间计算剩余有效期,TTL 不低于 60 秒
部分用户扫码无反应二维码内容过长导致识别失败,或 URL 里带了特殊字符抓取二维码内容长度,检查参数编码二维码内容控制在 200 字节内,参数做 URL 编码
确认后登录的是别人的账号票据未与会话绑定,被替换或抢先扫描检查票据生成与轮询的绑定校验加上会话绑定校验,异常时立即作废票据
高峰期轮询接口超时轮询频率过高,接入层被打满看接口 QPS 和平均响应时间前端分阶段降频,服务端对同一票据做短时间的结果缓存
用户重复点击确认报错确认接口不幂等检查状态流转的原子性重复确认按成功返回,或前端做防抖

这张表里的每一条基本都是我或者同事真踩过的。尤其是“临时凭证被抢走”这一条,它只在高并发下偶发,本地怎么测都正常,上线之后每天有几例,排查起来非常折磨。根因是同一个用户开了两个标签页,两个页面都在轮询同一个票据,谁先拿到凭证谁就登录成功,另一个永远等不到。后来的解法是在票据上记录“已消费”标记,第二个请求直接返回明确状态,前端提示“已在其他窗口登录”。

6.2 几个不带文档的经验

第一条,二维码的有效期不要做成固定的绝对值,而要在前端展示倒计时。用户看到倒计时会有紧迫感,扫码成功率明显提升;同时倒计时归零时前端主动刷新,避免用户对着一个已经失效的码反复扫。这个小改动对转化率的影响比想象中大。

第二条,扫码成功的提示音和震动反馈别省。用户扫码的时候注意力在电脑屏幕上,手机可能还在口袋里掏出来,一个明确的反馈能减少“我到底扫上没有”的疑惑,也就减少了重复扫码导致的无效请求。

第三条,给运维留一个手动作废票据的入口。出现过一次线上事故,某个票据因为逻辑缺陷一直不进入终态,用户卡在登录页出不来,最后是手动清了这个 key 才恢复。从那以后我做的每个类似系统都会留一个内部接口,能按票据编号强制作废,权限收紧到只有运维能调。

第四条,日志里打印状态流转的全链路。票据生成、扫码、确认、凭证签发、凭证消费,每个节点打一条带票据前缀的日志。出问题的时候按前缀一搜,整条链路清清楚楚。这个习惯帮我省掉了很多次线上排查的时间,比任何监控面板都直接。

7. 我个人对这道题的理解

回到面试那个场景。如果现在让我重新回答“二维码扫码登录的原理”,我会先讲清楚它的本质是跨设备信任传递,再画一遍状态机,然后重点讲两件事:一是为什么确认之后要用一次性临时凭证而不是直接下发会话,二是票据泄露、抢先扫描这些攻击怎么分层防住。这两块讲明白了,基本就说明你不仅会用,还知道它在什么情况下会坏。

至于那些实现细节,比如轮询间隔怎么定、票据有效期设多少、长轮询的连接怎么管,反而是次要的,因为这些都是可以调的参数,不是设计的核心。面试官真正想看的是你有没有把一条链路的每个环节都想清楚,包括失败路径和攻击面。我自己带人的时候也是这个标准:能把正常流程跑通的人很多,能把异常流程说清楚的人不多。

最后分享一个我一直在用的小方法。每设计一个涉及“临时凭证”的流程,我都会在纸上写两行字:这个凭证能被谁拿到,拿到之后能干什么,能撑多久。三个问题答完,方案的安全等级基本就定了,剩下的就是按需加码。扫码登录也好,其他授权流程也好,套路都是相通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询