算下来,写Python模拟抖音扫码登录这个方向的人不少,但大部分人都会卡在一个尴尬的位置:二维码生成了、手机也扫了、页面上明明显示"登录成功",结果你拿着这个成果去请求业务接口,反手就是一个403或者401。我前阵子帮一个读者排查类似问题,看到他把代码发过来的时候,第一反应就想说:这不是登录逻辑写得不对,是模拟请求时最容易被忽视的两个字段在搞鬼——Referer和Token。
这两个词在浏览器世界里司空见惯,但在你用Python手动构造请求时,就不是"浏览器自动带上"那么简单了,每一个header、每一个凭证都有它各自的时序和归属,错一个就能让你的登录成果瞬间作废。
这篇文章不打算从零教你怎么扫码,而是把模拟扫码登录从生成二维码到换Token的全链路拆开,重点盯着Referer和Token这两类校验问题,把我在实际操作里踩过的坑、排查过的错误码、验证过的细节都整理出来。只要你是用Python写客户端模拟抖音扫码登录、或者做类似Web端登录自动化、再或者只是对登录协议感兴趣想搞懂原理,这篇都值得你花十分钟读完。
1. 起因:一次"登上了却又被秒踢"的模拟登录事故
先给你还原一个我再熟悉不过的场景。有位读者自己写了一个模拟扫码登录的Python脚本,核心思路很清晰:先请求二维码接口拿到登录二维码,然后用户用手机抖音扫码,脚本在后台轮询登录状态,等手机端确认之后拿到登录凭证。他跑完整个流程都很顺,二维码能出来,扫码之后几秒钟,轮询接口真的返回了"已确认",凭证也拿到了。
但接下来的事情就让他抓狂了:拿着这个凭证去请求用户主页、粉丝列表这类业务接口,返回来的却是一串403,偶尔还会碰到401。他在本地反复试了好几次,换IP、清Cookie、重新生成二维码,问题依旧。他一度怀疑是抖音的风控把IP封了,直到他把脚本发给我,我扫了两眼就发现了门道。
他的请求头大概是这样的:
headers = { "User-Agent": "Mozilla/5.0 ...", "Content-Type": "application/json", "Cookie": "...", "Authorization": "Bearer xxx" }看着没毛病对吧?但注意:Referer从头到尾没有出现过。而且他放在Authorization里的Token,根本就不是扫码登录轮询接口返回的那个登录凭证,而是他在生成二维码阶段从某个接口顺手拿到的临时标识。也就是说,他拿错了Token,感觉就像拿着停车场的入场券去当高铁票刷,闸机当然不会给你开门。
这个例子给我最大的触动是:很多人把"模拟扫码登录"理解成"只要走完流程就行",但实际上,登录流程走完只是开始。后续每个业务请求都要重新拿二维码阶段、扫码阶段、换Token阶段这三个不同环节的成果出来组合使用,任何一个环节取错值,都会导致后续所有请求全部失败。
所以这篇避坑指南,我想先帮大家建立一个完整的链路认知,再分别把Referer和Token这两条线上最容易踩的坑一个个说清楚。
2. 先看清链路:扫码登录过程中,Referer和Token分别在哪个环节起作用
我在排查问题的时候有个习惯:不管代码多乱、报错多奇怪,先退一步把完整的前后链路画出来。因为绝大多数看似诡异的问题,只要你清楚每一步是谁在调用谁、参数从哪来、校验在哪一层做,答案基本就浮出水面了。
2.1 扫码登录的四个阶段
抖音的扫码登录,从请求到最终获得可用凭证,大致可以拆成四个阶段:
- 初始化阶段:客户端向服务端注册一个设备,拿到设备ID(比如
device_id),同时获取一些基础参数。 - 二维码生成阶段:用设备信息和必要的签名参数请求二维码接口,服务端返回二维码内容(通常是一个URL或token串)和一个二维码标识。
- 轮询扫码状态阶段:客户端拿着二维码标识轮询服务端,检查用户是否已扫码、是否已确认。这个阶段是长轮询或短轮询不一定,抖音一般在几秒到十几秒之间轮询一次。
- 换取Token阶段:当轮询接口返回"已确认"状态后,客户端用这个状态凭据去请求认证接口,换取正式的登录Token。
这四步走完之后,你手里的access_token和refresh_token才是后续调用业务接口时真正要被校验的凭证。
2.2 Referer在哪个环节起作用
先说结论:Referer几乎贯穿所有HTTP请求阶段,但在二维码生成阶段和Token换取阶段表现得最敏感。
这背后的逻辑不复杂。Web端页面在加载资源、发起XHR请求时,浏览器会自动在请求头里带上Referer,告诉服务端"我是在哪个页面上发起这个请求的"。而抖音的Web服务端拿到一个请求时,会校验这个Referer是否符合预期——只有来自https://www.douyin.com/或其子路径的请求,才会被认为是"从官方页面发出的正常请求"。
如果你用Python直接构造请求,没有带Referer,或者带了一个不符合预期的Referer,服务端就会把这个请求判定为"来源不明"。尤其是换取Token这步,许多人的二维码流程跑得通,但Token请求一发出去就被403,原因往往就是这。
2.3 Token在哪个环节起作用
Token校验的核心集中在四步中的后两步:轮询阶段确认身份时会校验二维码本身的时效token,换取Token阶段则是把上一步的确认结果"升级"成正式的access_token,后续业务请求再拿access_token去调用。
这里有个常见的误解:认为只要二维码能生成、手机能扫出来,就等于登录成功。实际上扫码确认只是拿到了"换取Token的资格",真正决定你能不能用的是最后的Token接口是否返回了有效的access_token,以及这个Token跟你初始化阶段的设备信息是否能对应上。Token和设备的绑定关系,我只说一句:它不是简单放在包里就算数,改成任意一个设备字段就要重新登录,这个结论后面会展开。
理解完链路,我们再分别把这两个隐藏大坑掰开揉碎讲清楚。
3. Referer校验的坑:缺失、错域、带错参数
很多写过爬虫或者做过Web自动化的人,对Referer都有个模糊的印象:好像带上就行。我身边就有不少人图省事,随便从浏览器复制一个Referer塞进代码全局,然后所有请求都走这一个配置。这种"无脑带Referer"的做法,在抖音的接口校验面前往往是第一个翻车点。
3.1 Referer校验到底在防什么
要搞清楚为什么抖音的接口对Referer这么敏感,先得明白服务端设这道校验的目的。简单来说,Referer校验主要防两类事:
- 防CSRF:避免用户在不知情的情况下,让恶意网站替自己发起请求。服务端校验Referer,确保请求来自用户自己操作的页面。
- 防跨域/防盗链:抖音的接口只希望被官方页面调用,如果别的网站或者别的客户端直接请求,服务端有权拒绝。
对于模拟登录脚本来说,你发的请求其实没有一个真实浏览器页面在背后支撑,所以Referer必须由你在代码里手动构造,构造不对,就会被识别成"非官方来源"。
3.2 最容易翻车的几个细节
以我的观察,Referer相关的问题集中在下面三处:
错误一:完全不带Referer
这个最常见。requests库默认不会帮你加任何Referer,你不写就是没有。抖音服务端收到一个没有Referer的登录请求,哪怕其他参数都对,也会被风控系统盯上。
错误二:Referer和接口所属域不匹配
抖音有很多不同的域,PC端Web页面是www.douyin.com,移动端H5页面是m.douyin.com,还有不同的业务子域和API网关域。如果你请求的接口生命周期里一直用同一个Referer,尤其是拿PC端Referer去请求移动端接口,就会触发校验失败。
我以前排查过一个案例:代码里请求的是H5端的二维码接口,却把Referer写成了https://www.douyin.com/,服务端返回的报错信息根本不明说,就是一个通用的403。后来我单独抓包对比才发现,这个接口期望的Referer是https://m.douyin.com/。
错误三:Referer格式和标准不一致
还有一类错误是格式上看着像、实际上不对。比如有人写成www.douyin.com,前面少了协议头https://;有人写成douyin.com,少了www子域;还有人把Referer里的路径写错了,比如https://www.douyin.com/写成了https://www.douyin.com/passport/,而实际接口期望的是根路径。这些细微差别,在服务端做精确匹配时都会被直接拒绝。
3.3 正确构造请求头的姿势
我的建议是:不要全局只设一个Referer,而是按照接口所在的页面域来分组设置。
import requests # 建立一个Session,统一管理Cookie和基础请求头 session = requests.Session() # 基础请求头 session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", }) # 请求PC端Web接口时,更新Referer为PC主页 def set_pc_referer(): session.headers.update({ "Referer": "https://www.douyin.com/" }) # 请求移动端H5接口时,更新Referer为H5主页 def set_mobile_referer(): session.headers.update({ "Referer": "https://m.douyin.com/" })核心思路就是:你的请求模拟的是哪个端的页面行为,就带哪个端的Referer。不要从浏览器复制一个Referer就从头用到尾,不同接口的Referer很可能不一样,最靠谱的方式是抓包看真实客户端请求里带了什么,然后照着适配。
如果你没有抓包经验,也可以自己去浏览器开发者工具里看:打开抖音Web页面,登录状态下随便触发几个接口,在Network面板里点开请求头,找Referer字段,你会发现不同接口的Referer确实有细微差别。把这些实际观察到的值收集起来,你就知道该怎么分组了。
4. Token生命周期管理的坑:过期、失效、刷新时序
Referer的问题相对线性,只要理解了域和格式,基本不会再翻车。Token这块就复杂多了,它不是一个固定值,而是有生命周期、有时序、有绑定关系的一整套机制。我在这个上面踩过的坑最疼,也最值得拿出来单独讲。
4.1 三种Token的身份定位
模拟抖音扫码登录时,你至少要跟三种Token打交道。我整理了一张表格,方便你对照理解:
| Token类型 | 出现阶段 | 主要作用 | 时效特征 |
|---|---|---|---|
| 设备token / device_token | 设备初始化阶段 | 标识当前设备,后续所有请求的基础凭证 | 和设备绑定,一般长期有效,但设备参数变了就失效 |
| 二维码token | 二维码生成阶段 | 标识一次扫码登录会话,轮询时验证会话有效性 | 短时效,通常几分钟内有效,过期需重新生成 |
| access_token / refresh_token | 扫码确认后换取 | 访问业务接口的正式凭证,refresh_token用于续期 | access_token短时效(几小时到几天),refresh_token相对长 |
很多人第一次模拟登录,等到轮询返回"确认成功"后,顺手就从响应里抓了一个看起来像token的字段塞进后续请求。但抖音登录接口的响应里往往同时存在多个token字段:有用于刷新凭证的refresh_token,有用于标识会话的session_key,还有形形色色的辅助字段。每个字段的用途不同,用错位置就会报错。
4.2 扫码登录成功不等于Token永久有效
我见过最多的一种"失灵"是这样的:上午扫码登录成功,拿到了token,脚本跑得很正常;下午再跑同一个脚本,token直接失效,返回401未授权。很多人第一时间怀疑是账号被风控了,但实际上很可能只是access_token的自然过期。
简单来说,access_token就像一张短期停车券,refresh_token才是你用来续时长的手段。你不能拿着一张过期停车券硬闯闸机,应该先拿refresh_token去续一张新券,再拿新券去请求接口。
这个阶段最容易踩的坑有两类:
- 没有做Token持久化:每次重新运行脚本都重新扫码,忽略已经有可用Token的事实,白白增加风控风险。
- 不处理401状态:当Token过期服务端返回401时,没有触发刷新逻辑,而是一直用过期Token重试,越试越被风控盯上。
4.3 刷新与续期的正确流程
我建议你在代码里把Token刷新做成一个独立的函数,请求业务接口前检查Token的过期时间,如果接近过期就先刷新再请求。示意代码如下:
import time class DouyinLoginSession: def __init__(self, session, device_id, access_token, refresh_token, expires_at): self.session = session self.device_id = device_id self.access_token = access_token self.refresh_token = refresh_token self.expires_at = expires_at def is_token_expired(self): # 提前5分钟判断过期 return time.time() + 300 >= self.expires_at def refresh_access_token(self): # 真实接口路径以你自己的抓包为准,这里用伪代码示意 url = "https://xxx.douyin.com/api/refresh" self.session.headers.update({ "Referer": "https://www.douyin.com/" }) payload = { "device_id": self.device_id, "refresh_token": self.refresh_token } resp = self.session.post(url, json=payload) data = resp.json() # 更新新的access_token和过期时间 self.access_token = data["access_token"] self.expires_at = time.time() + data["expires_in"]这里有几个要点需要注意:
- 刷新接口本身也有Referer校验,别只给业务接口加Referer,刷新Token的接口同样要加。
- 刷新Token是否有效,取决于设备参数是否和登录时一致。如果你在代码里改了设备型号、系统版本、设备ID这类字段,刷新很容易失败,因为服务端认为你换了一台设备。
- 刷新频率不能过高。每刷新一次服务端都有一笔记录,频繁刷新反而会被判定为异常行为。
4.4 Token与设备信息绑定的隐形关系
模拟扫码登录还有一个隐形的坑:Token不是独立存在的,它和初始化阶段的设备信息、User-Agent等参数强绑定。简单来说,你用什么"设备"去登录,后续请求就必须继续用这个"设备"的身份去发。
我调试时碰到过一种情况:登录成功后,我把requests库里的User-Agent换了,从指纹浏览器里复制了一个看起来更像真机的UA,结果所有请求立刻返回401。当时我百思不得其解,后来发现抖音的Token校验会比对当前请求的User-Agent和登录时的User-Agent是否一致。只要不一致,哪怕Token本身没过期,也会被判定为"身份存疑"。
正确的做法是:从初始化阶段开始,到登录成功,再到后续业务请求,全程保持同一个Session、同一个User-Agent、同一组设备参数。不要中途换请求头、换代理IP、换设备参数。这也是为什么我建议用requests.Session()来管理登录态,它能帮你自动维持整个会话过程中的Cookie和部分请求头一致。
5. 面对403/401/422,怎么一步步定位校验问题
在模拟登录的实际调试过程里,你遇到的错误码基本会集中在几个固定的状态码上。我一开始遇到403就慌,还以为自己被风控拉黑了,后来才总结出一套从状态码到根因的定位方法。这一节直接给你一条可以照着操作的排查链路。
5.1 先把状态码和对应问题对应起来
| 状态码 | 典型含义 | 最可能的原因 |
|---|---|---|
| 401 Unauthorized | 身份凭证无效 | Token缺失、过期、被改过、或与设备参数不匹配 |
| 403 Forbidden | 服务端拒绝请求 | Referer来源异常、签名校验失败、频率触发风控 |
| 422 Unprocessable Entity | 请求格式不对 | 缺少必填参数、JSON字段类型错误 |
| 400 Bad Request | 请求本身有误 | 参数拼接错误、Headers字段格式不对 |
需要注意的是,这些状态码在抖音的实际接口里不一定严格按照标准语义返回。我见过有些接口在Token失效时返回403而不是401,也见过Referer错误时返回400。所以状态码只是最初的信号,真正的排查还得往下追。
5.2 一次完整的排障过程
我给你完整过一遍我排查"扫码成功但获取用户信息401"这类问题的思路。
第一步:确认Token来源
我先确认代码里用的access_token到底是从哪个接口的哪段响应里取出来的。很多人会在这一步发现,自己取的其实是二维码阶段的一个临时字段,而不是登录后的正式凭证。判断方法很简单:在打印日志时把获取凭证的那个接口完整响应打出来,对照字段名逐一确认。
第二步:确认请求头是否完整
确认Authorization头已经带上access_token,且格式正确。同时检查Referer是否存在、是否和接口对应。这一步能排除掉大量低级问题。
第三步:确认设备和会话是否一致
检查当前请求的User-Agent、device_id等字段和登录时是否完全一致。很多人会在代码中不经意地重新初始化一个Session,导致Cookie丢失、设备参数变化,Token自然就失效了。
第四步:确认Token是否过期
如果前三步都没问题,那就得看Token本身是否过期了。把Token解析一下——抖音的access_token一般带有过期时间信息,或者回忆一下这个Token是什么时候换取的。如果时间超过了有效期,直接换成刷新逻辑重新拿Token再试。
第五步:确认请求频率是否触发风控
这一步我放在最后,是因为它通常是不得已才能确认的原因。如果你确认了前四步都没问题,而且请求频率明显高于正常人类操作,比如几十毫秒一次连续请求,那大概率是被风控临时限流了。这种时候别急着写重试循环,停下来等几分钟再试,或者降低轮询频率,比盲目换IP更有效。
5.3 排障时最有用的一个习惯:打印完整请求头
我还想再分享一个我自己坚持了很多年的习惯:调试的时候,永远把完整请求头和响应体打出来,而不是只看状态码。
# 调试利器:打印请求详情 def debug_request(resp): print("=== Request URL ===") print(resp.request.url) print("=== Request Headers ===") for k, v in resp.request.headers.items(): print(f"{k}: {v}") print("=== Response Status ===") print(resp.status_code) print("=== Response Body ===") print(resp.text[:1000])因为很多时候服务端返回的报错信息非常隐晦,不会直接告诉你"Referer不对"或者"Token过期"。但你把整个请求头打出来,至少可以确认你的代码当时实际发送了什么。比如你可能在调试时改了一半变量,结果发出去的Referer还是上一轮的旧值,这种问题不看实际请求头根本发现不了。
6. 实操中总结的几条经验:稳定比酷炫更重要
文章聊到这里,技术链路和坑基本都过了一遍。最后我想换个角度,聊聊我在实际做这类模拟登录项目时沉淀下来的一些开发习惯和观点。
第一,仿真要一致,不要混合拼接。
模拟客户端最重要的原则不是每个字段都模拟到一模一样,而是"内部一致性"。也就是说,你的设备信息、User-Agent、Referer、Token、Cookie、IP,这些要素在同一个会话里必须来自同一个"身份",不能东拼西凑。我见过有人把从浏览器抓包的Cookie和用Python注册的新设备混在一起用,结果请求头里同时出现了两个设备的矛盾信息,不报错才怪。保持一致性是模拟登录的底层逻辑,比任何技巧都重要。
第二,不要一上来就对高频接口做并发测试。
很多人的脚本跑通之后,第一反应是开多线程并发,想看看性能。这个动作在模拟登录场景里非常危险。正常用户的请求是有节奏的,你在接口上做高频并发,等于在风控系统里自报家门。我的习惯是先单线程跑通全流程,确认稳定之后再考虑是否需要并发,而且并发倍数宁小勿大,加上随机延时。
第三,一定要做Token的本地持久化。
很多人写脚本不注重状态保存,每次运行都重新走一遍扫码流程。且不说频繁扫码会增加账号的疲劳度,光是这种"每次都重新初始化"的方式,就让自己失去了观察Token生命周期变化的机会。把Token、过期时间、设备信息存到本地文件或者数据库里,下次脚本启动时先加载,判断过期再决定是否刷新。这也是提升稳定性的关键一步。
第四,合规使用,别越线。
模拟扫码登录这套技术,本质上是客户端与服务端之间的认证交互模拟。它合理的用途包括:个人数据备份、账号自动化管理、客户端开发调试、协议学习研究。但拿它去做批量注册、批量刷量、爬取他人隐私数据、绕过平台风控抢占资源,就明显越线了,既违反平台规则,也可能触碰法律风险。这也是我写技术分享时一直坚持的边界:讲原理、讲思路、讲排查方法,但不教人钻漏洞、避开所有风控。
第五,把"为什么"想明白了,比复制一百行代码有用。
我在帮人排查这类问题时发现,大部分人不是不会写代码,而是对整个链路没有一个完整的画面。他们知道要带Referer,但不知道为什么不同接口的Referer不一样;知道要用Token,但不知道Token和设备是绑定的。一旦你把这些"为什么"理解了,遇到任何状态码错误,都不会慌,因为你已经有一个定位问题的地图了。
如果你正在模拟抖音扫码登录,卡在某个Referer或者Token的报错上,我建议你先别急着换IP或者清缓存,静下心来把从初始化和二维码请求开始的所有请求头抓一遍,对照本文提到的几个坑逐一排查。很多时候,问题就藏在那个你复制进来之后就一直没看过的Referer值里。