网站扫码授权后换取用户凭证
在自己的服务端校验授权回调后,用一次性 code、ClientKey 和 ClientSecret 交换用户 access_token。
← 返回平台功能接入准备
- 组织主体完成网站应用创建和审核,应用已取得 user_info 权限,按官方要求设置自己的 HTTPS 授权回调。
- 准备自己的服务端登录会话与回调处理;同一次授权生成随机 state 并保存在会话中,回调先校验 state、用户会话与实际 scopes,再交换 code。下方不是完整登录网站。
- Python 3 标准库环境;ClientSecret 与凭证存储只在服务端,DY_TOKEN_FILE 指向已存在的私有目录。
准备环境变量
示例值需要替换为自己的凭证或资源 ID。代码在本机或服务端运行;密钥不应放进公开网页。
DY_CLIENT_KEY- 当前网站应用 ClientKey。
DY_CLIENT_SECRET- 同一应用的服务端密钥,不属于授权 URL 参数。
DY_AUTH_CODE- 自己的回调完成 state 和会话校验后得到的未消费 code。
DY_TOKEN_FILE- 私有服务端目录中本用户的凭证文件;请求前预建 0600 临时文件确认可写,成功后原子替换。
export DY_CLIENT_KEY='tt_REPLACE_WITH_CLIENT_KEY'
export DY_CLIENT_SECRET='REPLACE_WITH_SERVER_SECRET'
export DY_AUTH_CODE='REPLACE_WITH_VALIDATED_CODE'
export DY_TOKEN_FILE='/secure/douyin-user-token.json'以上 export 适用于 macOS / Linux 的终端。按调用步骤保存代码文件,再在同一终端执行。
调用步骤
- 01
在浏览器打开 https://open.douyin.com/platform/oauth/connect?client_key=自己的ClientKey&response_type=code&scope=user_info&redirect_uri=已URL编码的HTTPS回调&state=与会话绑定的随机值 。不要用后台 HTTP 请求代替用户扫码;ClientSecret 不能放进该链接。
- 02
用户同意后回到自己的回调;验证一次性 state 后读取 code 和实际 scopes。回调 URL 不携带自定义 query,缺少 user_info 同意时停止资料调用。
- 03
将已校验的 code、应用凭证和私有文件路径设置为下方环境变量;保存 Python 代码为 token.py,运行 python3 token.py。授权码仅有效 10 分钟且仅能用一次。
- 04
代码在请求前检查全部环境变量并预建 0600 临时文件;成功保存 client_key、open_id、received_at、access_expires_at 和 refresh_expires_at。后续按绝对截止时间检查,切勿把整份 token 文件上传到版本库。
以表单交换授权码
POST 表单请求,不是 JSON,也不是小程序 tt.login 的登录 code。下方请求体仅展示字段,完整代码从环境变量读取实际敏感值。
https://open.douyin.com/oauth/access_token/请求头
Content-Typeapplication/x-www-form-urlencoded
参数说明
参数表可左右滑动,查看完整说明。
| 字段 / 位置 | 类型 / 必填 | 说明 |
|---|---|---|
client_key请求体 | string必填 | 发起授权所用网站应用 ClientKey。 |
client_secret请求体 | string必填 | 同一应用 ClientSecret,只在服务端取用。 |
code请求体 | string必填 | 校验过 state 的一次性用户授权码,10 分钟内使用。 |
grant_type请求体 | string必填 | 固定 authorization_code。 |
调用代码
替换示例 ID 与请求体内容后运行。
import json
import os
import time
import tempfile
from pathlib import Path
from contextlib import contextmanager
from urllib.parse import urlencode
import urllib.error
import urllib.request
def env(name):
value = os.environ.get(name)
if not value or not value.strip() or "REPLACE_WITH" in value:
raise SystemExit("请设置真实环境变量: " + name)
return value
def positive_int(value, name):
if isinstance(value, bool):
raise SystemExit("无效期限字段: " + name)
try:
number = int(value)
except (TypeError, ValueError):
raise SystemExit("无效期限字段: " + name)
if number <= 0:
raise SystemExit("无效期限字段: " + name)
return number
def load_record(path):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
raise SystemExit("无法读取原凭证文件;请检查路径与安全存储格式")
if not isinstance(record, dict):
raise SystemExit("原凭证文件格式错误")
return record
@contextmanager
def pending_file(value):
path = Path(value).expanduser().absolute()
if path.is_symlink() or (path.exists() and not path.is_file()):
raise SystemExit("凭证目标必须是私有目录中的普通文件,不能是目录或符号链接")
# 先创建 0600 临时文件并确认可写,再消费授权码或发送刷新请求。
# 这能前置发现路径配置错误;不能保证请求后永不发生磁盘或系统故障。
try:
output = tempfile.NamedTemporaryFile("w", encoding="utf-8",
dir=path.parent, prefix=".douyin-", delete=False)
except OSError:
raise SystemExit("无法在凭证目录创建文件;尚未发送请求")
try:
os.chmod(output.name, 0o600)
output.flush()
os.fsync(output.fileno())
yield path, output
finally:
output.close()
try:
os.unlink(output.name)
except FileNotFoundError:
pass
def post(url, payload):
request = urllib.request.Request(url, data=payload,
headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=30) as response:
result = json.load(response)
except urllib.error.HTTPError as error:
raise SystemExit("HTTP " + str(error.code) + ";不输出可能含凭证的原文")
except (urllib.error.URLError, TimeoutError):
raise SystemExit("网络或超时错误;不要盲目重复消费一次性授权码")
except (ValueError, UnicodeError):
raise SystemExit("响应不是有效 JSON,原凭证文件未覆盖")
if not isinstance(result, dict) or not isinstance(result.get("data"), dict):
raise SystemExit("响应缺少 data,原凭证文件未覆盖")
data = result["data"]
if str(data.get("error_code")) != "0" or not data.get("access_token"):
raise SystemExit("业务请求失败,业务码: " + str(data.get("error_code")))
if not isinstance(data.get("open_id"), str) or not data["open_id"]:
raise SystemExit("响应缺少用户归属,原凭证文件未覆盖")
return data
def commit(path, output, record):
json.dump(record, output, ensure_ascii=False)
output.flush()
os.fsync(output.fileno())
output.close()
os.replace(output.name, path)
print("凭证已安全保存;请按绝对截止时间检查有效期,不输出凭证")
# 所有配置先读取和检查,缺失项不能在 code 已消费后才被发现。
client_key = env("DY_CLIENT_KEY")
client_secret = env("DY_CLIENT_SECRET")
auth_code = env("DY_AUTH_CODE")
token_file = env("DY_TOKEN_FILE")
with pending_file(token_file) as (path, output):
previous = load_record(path) if path.exists() else None
if previous and (previous.get("client_key") != client_key or not previous.get("open_id")):
raise SystemExit("原凭证文件缺少归属或属于其他应用;改用该用户的独立文件")
body = urlencode({"client_key": client_key, "client_secret": client_secret,
"code": auth_code, "grant_type": "authorization_code"}).encode("utf-8")
started_at = int(time.time())
data = post("https://open.douyin.com/oauth/access_token/", body)
received_at = int(time.time())
if previous and previous["open_id"] != data["open_id"]:
raise SystemExit("返回用户与原文件不一致;没有覆盖原用户凭证")
if not isinstance(data.get("refresh_token"), str) or not data["refresh_token"]:
raise SystemExit("响应缺少刷新凭证,原文件未覆盖")
# 以请求开始时刻加服务端时长,避免把网络耗时计入可用期限。
record = dict(data, client_key=client_key, received_at=received_at,
access_expires_at=started_at + positive_int(data.get("expires_in"), "expires_in"),
refresh_expires_at=started_at + positive_int(data.get("refresh_expires_in"), "refresh_expires_in"))
if min(record["access_expires_at"], record["refresh_expires_at"]) <= received_at:
raise SystemExit("响应到达时期限已过,原文件未覆盖")
commit(path, output, record)
返回示例
成功数据节选;expires_in 等为秒,示意值来自文档结构,实际有效期以当次响应为准。 以下为结构示意,非本站实测结果。
{
"data": {
"error_code": 0,
"access_token": "ACT_EXAMPLE_NOT_A_REAL_TOKEN",
"expires_in": 1296000,
"open_id": "OPEN_ID_EXAMPLE",
"refresh_token": "RFT_EXAMPLE_NOT_A_REAL_TOKEN",
"refresh_expires_in": 2592000,
"scope": "user_info"
},
"message": "success"
}常见错误与排查
10007授权码失效或已消费- 重新让用户主动授权;不要重放旧 code。
10003 / 10013 / 10014应用密钥或 ClientKey 不匹配- 核对 ClientKey、ClientSecret 与发起授权使用的应用。
10002参数或编码错误- 检查四个表单字段并使用 application/x-www-form-urlencoded。
调用注意
- HTTP 200 仍检查 data.error_code;不把业务错误当作已登录。
- 示例仅保存凭证,不创建自己的业务会话;登录产品必须把校验后的授权结果绑定回发起授权的会话,不能信任前端任意传来的 open_id。
注意事项
- 客户端只取得授权码,交换和存储凭证均在服务端完成。
- received_at 是本机接收时间;绝对截止时间按请求开始时间加响应时长保守计算。现存凭证文件要有 client_key/open_id,且只能覆盖同一应用同一用户;错误和失败临时文件不会覆盖原文件。
- 用不同文件或数据库主键隔离应用与用户;此示例没有提供集群锁、加密凭证库或定时刷新任务。
- 授权 URL 构造与扫码需在自己的网页和回调中集成,本站未进行真实扫码授权。
核验来源
本站按官方资料整理本场景的必要调用步骤与代码,覆盖范围以本页列出的接口为准。官方资料用于追溯规范及查看变更。
- 01网站应用手机号和扫码登录授权核验日期 2026-10-15
- 02抖音登录和授权核验日期 2026-10-15
- 03上线移动应用和网站应用核验日期 2026-10-15
- 04获取 access_token核验日期 2026-10-15
- 05抖音获取授权码核验日期 2026-10-15
