目录本文共 6 节
交易所 API 签名,就是用你的 Secret Key 对这次请求的内容计算一个 HMAC-SHA256 值,随请求一起发给交易所,交易所用同一个 Secret 重新算一遍,结果一致才执行。币安的签名原文是查询字符串(加请求体),结果输出为十六进制,放在 signature 参数里;欧易的签名原文是 timestamp + method + requestPath + body,结果做 Base64 编码后放进 OK-ACCESS-SIGN 请求头。下面用两家官方文档里的示例参数,只用 Python 标准库把签名一步步算出来,币安的结果与官方文档完全一致。
为什么交易所 API 需要签名?
API Key 只是一个公开的「账号」,每次请求都会明文发送。如果只凭 API Key 就能下单,任何截获请求的人都可以冒充你。签名解决了三个问题:
- 证明你持有 Secret:Secret 从不随请求发送,只用来计算签名,别人截获了请求也拿不到它。
- 防篡改:请求里的任何一个字符被改动,签名就对不上,交易所会拒绝。
- 防重放:签名原文里包含时间戳,过期的请求即使签名正确也会被拒绝,旧请求无法被反复提交。
服务器那一端的流程可以这样理解:收到请求后,先根据 API Key 找到对应的 Secret 和这个 Key 的权限、IP 白名单;再检查时间戳是否在允许的时间窗口内;然后按同样的规则把请求内容拼成签名原文,用 Secret 算出签名,和你发来的签名逐字比较。任何一步不通过,请求都不会进入撮合系统。所以签名错误不会产生任何订单,只是请求被拒绝,可以放心排查。
这也是为什么 Secret Key 和 Passphrase 一旦泄露,就必须立刻删除 Key:拿到 Secret 的人可以自己算出任何请求的有效签名。申请 Key 和设置权限的方法见 币安 API Key 申请教程 和 欧易 API 创建教程。
HMAC-SHA256 是什么?
SHA-256 是一种哈希函数:任意长度的输入,都会得到固定 32 字节的输出,输入改一个字符,输出就完全不同,而且无法从输出倒推输入。HMAC 是在哈希的基础上加入一个密钥,只有知道密钥的人才能算出相同的结果。用 Python 标准库直观感受一下:
import hashlib
import hmac
msg = b"hello"
print("SHA256 :", hashlib.sha256(msg).hexdigest())
print("HMAC key=a :", hmac.new(b"a", msg, hashlib.sha256).hexdigest())
print("HMAC key=b :", hmac.new(b"b", msg, hashlib.sha256).hexdigest())
print("摘要字节数 :", len(hmac.new(b"a", msg, hashlib.sha256).digest()))
运行输出:
SHA256 : 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
HMAC key=a : 71073df21b4bbf7b287cfb0135537dd21953934b12ec4ee1c393e26a98cd7c8c
HMAC key=b : b002a440dd7b0818b7e924189cbffad521961117cda6a71c41d6dad2d4caf4ae
摘要字节数 : 32
同样的消息,密钥不同,结果完全不同。32 字节的结果有两种常见写法:hexdigest() 转成 64 位十六进制字符串,币安用这种;digest() 拿到原始字节再做 Base64,得到 44 个字符,欧易用这种。两家用的是同一个算法,差别在于签什么和结果怎么编码。
币安 API 签名怎么算?
币安文档对 HMAC Key 的签名规则是:
- 把所有参数按
参数名=值用&连起来,非 ASCII 字符要先做百分号编码。 - 如果同时有查询字符串和请求体,签名原文是两者直接拼接,中间不加分隔符。
- 用 Secret Key 对原文做 HMAC-SHA256,结果转为十六进制。
- 把结果作为
signature参数附加到请求里,API Key 放在请求头X-MBX-APIKEY。
下面使用币安文档中公开的示例 Secret Key 和下单参数(这是官方演示用的值,不是任何人的真实密钥):
import hmac
import hashlib
from urllib.parse import urlencode
# 币安官方文档中的示例 secretKey(仅用于演示,不是真实密钥)
secret = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j"
params = {
"symbol": "LTCBTC",
"side": "BUY",
"type": "LIMIT",
"timeInForce": "GTC",
"quantity": "1",
"price": "0.1",
"recvWindow": "5000",
"timestamp": "1499827319559",
}
payload = urlencode(params) # 按插入顺序拼成 key=value&key=value
signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
print("payload :", payload)
print("signature:", signature)
print("与文档一致:", signature == "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71")
# 非 ASCII 交易对示例:先百分号编码再签名
params["symbol"] = "123456"
payload2 = urlencode(params)
sig2 = hmac.new(secret.encode(), payload2.encode(), hashlib.sha256).hexdigest()
print("payload2 :", payload2)
print("signature2:", sig2)
print("与文档一致:", sig2 == "e1353ec6b14d888f1164ae9af8228a3dbd508bc82eb867db8ab6046442f33ef3")
运行输出:
payload : symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559
signature: c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71
与文档一致: True
payload2 : symbol=%EF%BC%91%EF%BC%92%EF%BC%93%EF%BC%94%EF%BC%95%EF%BC%96&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559
signature2: e1353ec6b14d888f1164ae9af8228a3dbd508bc82eb867db8ab6046442f33ef3
与文档一致: True
两个示例的签名都与币安文档给出的结果一字不差。也可以用 OpenSSL 在命令行交叉验证,结果相同:
echo -n "symbol=LTCBTC&side=BUY&type=LIMIT&timeInForce=GTC&quantity=1&price=0.1&recvWindow=5000×tamp=1499827319559" \
| openssl dgst -sha256 -hmac "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j"
# SHA2-256(stdin)= c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71
最后,签名要追加在参数末尾:发送的查询字符串就是 payload + "&signature=" + signature。参数顺序本身没有强制要求,但发送的顺序必须和签名时完全一样。
timestamp 和 recvWindow 是什么规则?
截至 2026 年 10 月,币安文档的时间规则如下:
timestamp是发出请求时的毫秒时间戳(也支持微秒),每个签名请求都必须带。recvWindow是请求的有效期,单位毫秒,不传默认 5000,最大 60000,文档建议用 5000 或更小。- 服务器的判断逻辑是:
timestamp不能比服务器时间快 1 秒以上,且服务器时间减去timestamp不能超过recvWindow。
不满足就返回 -1021。我们用文档里 2017 年的示例时间戳去请求真实接口,币安的返回是:
{"code":-1021,"msg":"Timestamp for this request is outside of the recvWindow."}
这次请求用的是一个格式正确但并不存在的 Key,返回的却是 -1021 而不是 Key 无效,说明时间戳过期时会先被拦下。所以本机时钟不准时,你看到的往往是 -1021,而不是签名错误。解决办法是开启电脑的自动对时,或者先调用 GET /api/v3/time 计算本机与服务器的时间差,生成时间戳时加以修正。
用标准库发一个完整的币安签名请求
把上面的步骤串起来,就是一个完整的查账户余额请求。下面默认连币安现货测试网,Key 从环境变量读取:
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
from urllib.parse import urlencode
API_KEY = os.environ["BINANCE_API_KEY"]
SECRET = os.environ["BINANCE_API_SECRET"].strip()
BASE = "https://testnet.binance.vision" # 测试网;实盘为 https://api.binance.com
query = urlencode({"omitZeroBalances": "true", "recvWindow": 5000, "timestamp": int(time.time() * 1000)})
signature = hmac.new(SECRET.encode(), query.encode(), hashlib.sha256).hexdigest()
url = f"{BASE}/api/v3/account?{query}&signature={signature}"
req = urllib.request.Request(url, headers={"X-MBX-APIKEY": API_KEY})
try:
with urllib.request.urlopen(req, timeout=10) as r:
print(json.loads(r.read())["balances"])
except urllib.error.HTTPError as e:
print(e.code, e.read().decode())
我们用一组格式正确、但并未在币安登记的假 Key 运行,测试网返回:
401 {"code":-2015,"msg":"Invalid API-key, IP, or permissions for action."}
换成你自己在 testnet.binance.vision 生成的 Key,就会打印出测试网账户里的非零余额。omitZeroBalances=true 是币安文档里的可选参数,用来只返回余额不为零的币种。注意签名必须在所有参数(包括 timestamp)都确定之后再计算,signature 自己不参与签名。
币安的 Ed25519 签名有什么不同?
如果你用的是自行生成的 Ed25519 Key,签名原文的构造方法完全一样,只是第三步换成用私钥签名,结果做 Base64(再做百分号编码后放进 signature)。Python 标准库没有 Ed25519,需要 pip install cryptography:
import base64
from cryptography.hazmat.primitives.serialization import load_pem_private_key
with open("test-prv-key.pem", "rb") as f:
private_key = load_pem_private_key(f.read(), password=None)
payload = "symbol=BTCUSDT&side=SELL&type=LIMIT&timeInForce=GTC&quantity=1&price=0.2×tamp=1668481559918&recvWindow=5000"
signature = base64.b64encode(private_key.sign(payload.encode("ascii"))).decode()
print(signature)
币安文档没有公开 Ed25519 示例私钥,所以我们用 OpenSSL 本地生成了一对测试密钥:上面的 Python 代码、openssl pkeyutl 和 CCXT 三者算出的签名完全一致,并且用公钥验签通过。Ed25519 签名是确定性的,同一私钥、同一原文每次结果都相同。
欧易 API 签名怎么算?
欧易文档的规则是:
- 拼接签名原文:
timestamp + method + requestPath + body。 - 用 Secret Key 对原文做 HMAC-SHA256。
- 把结果的原始字节做 Base64 编码,放进请求头
OK-ACCESS-SIGN。
四个部分各有讲究:
| 部分 | 要求 | 示例 |
|---|---|---|
| timestamp | ISO 8601 格式的 UTC 时间,精确到毫秒,必须和请求头 OK-ACCESS-TIMESTAMP 相同 |
2020-12-08T09:08:57.715Z |
| method | 大写 | GET、POST |
| requestPath | 接口路径;GET 请求的查询参数也算在里面 | /api/v5/account/balance?ccy=BTC |
| body | 请求体 JSON 原文;GET 请求没有请求体,用空字符串 | {"instId":"BTC-USDT","lever":"5","mgnMode":"isolated"} |
欧易文档给出了示例 SecretKey 22582BD0CFF14C41EDBF1AB98506286D、示例时间戳和示例请求,但没有公布对应的签名结果。所以我们用文档的示例值计算,再用 OpenSSL 和 CCXT 两种独立实现交叉验证:
import hmac
import hashlib
import base64
import json
# 欧易官方文档中的示例 SecretKey 与时间戳(仅用于演示)
secret = "22582BD0CFF14C41EDBF1AB98506286D"
timestamp = "2020-12-08T09:08:57.715Z"
def okx_sign(timestamp, method, request_path, body, secret):
prehash = timestamp + method.upper() + request_path + body
digest = hmac.new(secret.encode(), prehash.encode(), hashlib.sha256).digest()
return prehash, base64.b64encode(digest).decode()
# 例 1:GET 请求,查询参数属于 requestPath,body 为空字符串
prehash, sign = okx_sign(timestamp, "GET", "/api/v5/account/balance?ccy=BTC", "", secret)
print("prehash:", prehash)
print("sign :", sign)
# 例 2:POST 请求,body 是发送出去的 JSON 原文
body = json.dumps({"instId": "BTC-USDT", "lever": "5", "mgnMode": "isolated"}, separators=(",", ":"))
prehash, sign = okx_sign(timestamp, "POST", "/api/v5/account/set-leverage", body, secret)
print("prehash:", prehash)
print("sign :", sign)
运行输出:
prehash: 2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC
sign : HiZhvSfMtWJA3uUIVXV3a/bSXNPCWvYFXoGCVS8V4zY=
prehash: 2020-12-08T09:08:57.715ZPOST/api/v5/account/set-leverage{"instId":"BTC-USDT","lever":"5","mgnMode":"isolated"}
sign : eCnnCgWLjlQ9XnpUkrcny3qNq3WW/81KNrDr/XR6Xv8=
OpenSSL 交叉验证,两条结果与 Python 完全相同:
printf '%s' '2020-12-08T09:08:57.715ZGET/api/v5/account/balance?ccy=BTC' \
| openssl dgst -sha256 -hmac 22582BD0CFF14C41EDBF1AB98506286D -binary | base64
# HiZhvSfMtWJA3uUIVXV3a/bSXNPCWvYFXoGCVS8V4zY=
我们还把 CCXT 的时钟固定在同一时刻,让它生成同样两个请求,它给出的 OK-ACCESS-SIGN 也分别是 HiZhvSfM…4zY= 和 eCnnCgWL…6Xv8=。三种实现结果一致,说明这套拼接方式就是欧易实际使用的规则。
实际请求时,时间戳要用当前 UTC 时间生成:
from datetime import datetime, timezone
ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
print(ts) # 例如 2026-10-22T09:30:00.123Z
欧易 WebSocket 登录的签名有什么不同?
欧易私有 WebSocket 频道(订单、账户推送)在登录时也要签名,算法相同,但有两处差别:时间戳是 Unix 秒级数字而不是 ISO 时间,method 固定为 GET,requestPath 固定为 /users/self/verify。用文档的示例 SecretKey 和示例时间戳 1704876947 计算:
import base64
import hashlib
import hmac
secret = "22582BD0CFF14C41EDBF1AB98506286D" # 欧易文档示例 SecretKey
timestamp = "1704876947" # 欧易文档示例:Unix 秒级时间戳
prehash = timestamp + "GET" + "/users/self/verify"
sign = base64.b64encode(hmac.new(secret.encode(), prehash.encode(), hashlib.sha256).digest()).decode()
print(prehash)
print(sign)
运行输出(与 OpenSSL 计算结果一致):
1704876947GET/users/self/verify
5/36BgGV6m/6pmdc20zdqk0mzF5ZalmzzPD2fo3wavU=
登录请求同样在时间戳 30 秒后过期。REST 和 WebSocket 的时间戳格式不同,是新手写欧易程序时很容易混淆的地方。
欧易时间戳出错会怎样?
欧易文档写明,时间戳与服务器时间相差超过 30 秒会返回 50102,最常见的原因是用了本地时区时间而不是 UTC。模拟盘请求的签名方法完全相同,只是额外加上请求头 x-simulated-trading: 1,这个请求头不参与签名。
币安和欧易的签名方式有什么区别?
| 对比项 | 币安(HMAC Key) | 欧易 |
|---|---|---|
| 签名原文 | 查询字符串 + 请求体 | timestamp + method + requestPath + body |
| 时间戳格式 | 毫秒数字,如 1499827319559 | ISO 8601 UTC,如 2020-12-08T09:08:57.715Z |
| 时间容差 | recvWindow,默认 5 秒,最大 60 秒 | 与服务器相差不超过 30 秒 |
| 输出编码 | 十六进制(不区分大小写) | Base64 |
| 签名放在哪 | signature 参数 |
OK-ACCESS-SIGN 请求头 |
| 其他认证信息 | X-MBX-APIKEY 请求头 |
OK-ACCESS-KEY、OK-ACCESS-PASSPHRASE、OK-ACCESS-TIMESTAMP |
| 其他 Key 类型 | 还支持 Ed25519、RSA | 仅 HMAC |
签名总是报错,最常见的原因有哪些?
几乎所有签名错误都可以归结为一句话:签名用的字符串和实际发出去的字符串不一致。逐项排查:
- JSON 多了空格。Python 的
json.dumps默认在冒号和逗号后加空格。我们用同一组数据实测,默认写法得到的签名是/XctMG4g…ofI8=,紧凑写法是eCnnCgWL…6Xv8=,完全不同。最稳妥的做法是先把 body 序列化成字符串,签名和发送都用这同一个字符串。 - 参数顺序或格式变了。签名后又让 HTTP 库重新编码参数,顺序可能改变;
0.1被写成0.10,或空格一处编码成+、另一处编码成%20,签名都会失败。 - 签名之后又改了参数。比如先算好签名,再往请求里追加一个参数,或者 HTTP 库自动补了默认参数,签名就失效了。所有参数都要在签名之前确定。
- 时间戳不一致。签名用了一个时间戳,请求头或参数里又重新取了一次当前时间。
- 欧易 GET 参数没算进 requestPath,或请求方法写成了小写
get。 - Secret 带了空白字符。从网页复制时多了空格或换行,读取环境变量时可以先
.strip()。 - 用错了环境的 Key。币安测试网、欧易模拟盘的 Key 和实盘互不通用,这类错误通常报 Key 无效而不是签名错误。
常见问题
币安 API 签名为什么报 -1022?
-1022 表示签名无效。最常见的原因是签名用的查询字符串和实际发出去的不一致,比如参数顺序变了、数字格式变了、非 ASCII 字符没有先做百分号编码,或者 Secret Key 复制时多了空格和换行。
欧易签名里的 requestPath 要不要带查询参数?
要带。欧易文档写明 GET 请求的参数算作 requestPath 的一部分,例如 /api/v5/account/balance?ccy=BTC 要整体参与签名;POST 请求的参数放在 body 里,requestPath 只写路径。
币安 recvWindow 设多大合适?
币安文档建议使用 5000 毫秒或更小的值,不传时默认就是 5000,最大不能超过 60000。recvWindow 越大,被截获的请求可被重放的时间窗口越长,网络稳定时不建议调大。
HMAC 签名和 Ed25519 签名有什么区别?
HMAC 是对称算法,你和交易所持有同一个 Secret;Ed25519 是非对称算法,你用私钥签名,交易所只保存公钥验证。币安两种都支持,并在文档里推荐 Ed25519;欧易 API 使用 HMAC-SHA256 签名。
可以用在线 HMAC 工具算签名吗?
用官方文档的示例 Key 练习可以,真实的 Secret Key 绝对不要粘贴到任何网页工具里。在线工具能看到你输入的全部内容,等于把 Secret 交给了陌生人,请只在自己电脑上用代码计算。
准备好出发了吗?
参考来源
- Binance Spot API 文档:SIGNED Endpoint Examples / Timing Security
- Binance Spot API 文档:API Key Types
- OKX API v5 文档:REST Authentication / Signature
- Python 官方文档:hmac 模块
以上信息核实于 2026-10-22,交易所规则可能调整,请以官方最新说明为准。
本文仅供学习参考,不构成投资建议。交易所的费率、活动与功能可能随时调整,请以官方页面为准。文中部分链接为推广链接,详见推广披露。