Short answer: An Ed25519 API key is an exchange API key backed by a key pair you generate: you register only the public key and sign each request with the private key, so the exchange never holds your signing secret. Binance calls HMAC keys deprecated without giving a date (key types page, last modified 30 October 2026); with Ed25519 you sign the exact query string, then Base64-encode and percent-encode the signature, and mistakes there return -1022.
Everything here was read on 5 November 2026 in official documentation and tested that day (Python 3.11.3, cryptography 43.0.1, OpenSSL 3.2.1) with synthetic keys and the published RFC 8032 test key only. Never paste a live private key or API secret anywhere: web page, chat, AI assistant or online checker. This page covers exchange API keys, not SSH keys.
This is a technical guide, not a recommendation of any exchange; automated trading can lose money, and availability depends on your jurisdiction and each platform's terms. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.
Ed25519 API key vs HMAC: what changes on Binance
Only the signature changes: header, permissions, IP restrictions and timestamps stay the same, but the exchange stores a public key that cannot sign.
- Status. Binance's API key types page, last modified 30 October 2026, lists Ed25519 (recommended), HMAC and RSA (2048 or 4096 bits) and states that "HMAC keys are deprecated".
- No date. Neither that page nor the Spot changelog (last updated 30 October 2026, read in full) gives a cutoff; HMAC is still listed as supported.
- Ed25519 only:
session.logonon the WebSocket API. The user data stream does not need it, sinceuserDataStream.subscribe.signature(12 August 2025) takes any key type.
Storage is covered in API key security for trading bots, key creation in how to create an exchange API key.
How to create an Ed25519 key pair
Generate the pair on the machine that will sign, and encrypt the private key with a passphrase.
openssl genpkey -algorithm ed25519 -aes-256-cbc -out api-ed25519.pem
openssl pkey -in api-ed25519.pem -pubout -out api-ed25519.pub.pem
Both worked on OpenSSL 3.2.1. In Python:
from cryptography.hazmat.primitives import serialization as s
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
key = Ed25519PrivateKey.generate()
private_pem = key.private_bytes(s.Encoding.PEM, s.PrivateFormat.PKCS8,
s.BestAvailableEncryption(b"passphrase-from-your-secret-store"))
public_pem = key.public_key().public_bytes(s.Encoding.PEM,
s.PublicFormat.SubjectPublicKeyInfo)
Loading it without the passphrase raises TypeError: Password was not given but private key is encrypted. Binance's Asymmetric Key Generator is a desktop app on GitHub (release v1.1.8, 2 November 2026); its README says to verify downloads with sha256sum -c against the .CHECKSUM file.
Ed25519 public key example and a test vector
An Ed25519 public key in PEM form has a 60-character body that always starts with MCowBQYDK2VwAyEA, the fixed header before the 32-byte key.
RFC 8032's first test key in PEM form; it is public, so treat it as compromised:
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g
-----END PRIVATE KEY-----
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
-----END PUBLIC KEY-----
An unencrypted Ed25519 private key from OpenSSL or Python starts MC4CAQAwBQYDK2VwBCIEI; an RSA-2048 public key starts MIIBIjAN and is far longer.
Test vector. Ed25519 is deterministic, so signing Binance's documented REST payload symbol=BTCUSDT&side=SELL&type=LIMIT&timeInForce=GTC&quantity=1&price=0.2×tamp=1668481559918&recvWindow=5000 with this key must give XtZirsmmi0noRzUfkqktvkVfxpkq/WtbLg2UOL3QGYdUBZVlqOBEMuEVw8zioY93N54NcKj9UuAXQEa9zgTDBg==. Python and openssl pkeyutl both did on 5 November 2026.
Binance's samples cannot do this job: no private key is published, its three Ed25519 sample signatures did not verify against its sample public key over the documented payloads, and the REST samples are RSA-sized; see API signature generation.
Register the public key and sign Binance requests
Register the public key as a self-generated key, then sign exactly the bytes you send.
- Register. Binance's support article (published 11 July 2023): Profile, API Management, Create API, Self-generated, paste the public key, name it, confirm with 2FA. The API key shown goes in the
X-MBX-APIKEYheader; testnet setup is in the Binance API Python guide. - REST. Per the REST API page (last modified 30 October 2026), sign the query string plus body, non-ASCII percent-encoded first, then Base64-encode and percent-encode the signature.
- WebSocket API. Sort every parameter except
signature,apiKeyincluded, by name, join them asname=valuewith&, sign without percent-encoding, and send the Base64 text in the JSON.
Binance's OpenSSL examples failed on OpenSSL 3.2.1: openssl dgst -sign, with or without -sha256, printed "Key type not supported for this operation" and output nothing, because Ed25519 signs the message, not a digest. openssl pkeyutl -sign -rawin matched Python.
session.logon
session.logon authenticates a WebSocket connection once (weight 2); later signed requests drop apiKey and signature but keep timestamp. Its sample shows a 64-character hex signature, the HMAC-SHA256 format; an Ed25519 signature is 88 Base64 characters. On the Spot Testnet on 5 November 2026, a logon with a synthetic key returned status 401 with -2015, then the server closed the socket with code 1008, which is undocumented. Stop on -2015 rather than retrying in a loop.
Ed25519 signing in Python, tested against a local mock
A local mock applying Binance's documented checks reproduces each signing failure without a real key (run 5 November 2026, requests 2.31.0):
import base64, json, threading, time, urllib.parse as up
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as Ed
KEY = Ed.from_private_bytes(bytes.fromhex( # RFC 8032 test key 1: never register it
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"))
API_KEY = "A" * 64
REGISTERED = {API_KEY: KEY.public_key()}
def sign(params, key=KEY, encode=True):
payload = up.urlencode(params)
sig = base64.b64encode(key.sign(payload.encode())).decode()
return payload + "&signature=" + (up.quote(sig, safe="") if encode else sig)
class Mock(BaseHTTPRequestHandler):
def log_message(self, *args): pass
def reply(self, status, code, msg):
body = json.dumps({"code": code, "msg": msg}) if code else msg
self.send_response(status); self.end_headers(); self.wfile.write(body.encode())
def do_GET(self):
url = up.urlsplit(self.path)
pairs = up.parse_qsl(url.query, keep_blank_values=True)
p = dict(pairs) # keeps the last duplicate, so check first
pub = REGISTERED.get(self.headers.get("X-MBX-APIKEY", ""))
if url.path not in {"/api/v3/account"}:
return self.reply(404, 0, "<html>not found</html>")
if len(pairs) > len(p):
return self.reply(400, -1101, "Duplicate values for a parameter detected.")
if p.get("signature") == "":
return self.reply(400, -1105, "Parameter 'signature' was empty.")
if not p.get("signature") or not p.get("timestamp", "").isdigit():
return self.reply(400, -1102, "A mandatory parameter was not sent, was empty/null, or malformed.")
if pub is None:
return self.reply(401, -2015, "Invalid API-key, IP, or permissions for action.")
try:
pub.verify(base64.b64decode(p["signature"], validate=True),
url.query.rpartition("&signature=")[0].encode())
except (InvalidSignature, ValueError):
return self.reply(400, -1022, "Signature for this request is not valid.")
self.reply(200, 0, '{"canTrade": true}')
class NetworkDown(Exception): pass
class Rejected(Exception): pass
def account(base, query, api_key=API_KEY):
if not (api_key.isascii() and api_key.isalnum()):
raise Rejected("malformed API key")
try:
r = requests.get(f"{base}/api/v3/account?{query}",
headers={"X-MBX-APIKEY": api_key}, timeout=5)
except requests.RequestException as exc:
raise NetworkDown(type(exc).__name__) from exc
try:
body = r.json()
except ValueError:
body = None
if r.status_code == 200 and isinstance(body, dict):
return body
body = body if isinstance(body, dict) else {}
raise Rejected(f"HTTP {r.status_code} {body.get('code', '-')} {body.get('msg', r.text[:25])}")
srv = HTTPServer(("127.0.0.1", 0), Mock)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base, now = f"http://127.0.0.1:{srv.server_port}", int(time.time() * 1000)
plus = [q for i in range(1000) if "+" in (q := sign({"timestamp": 1668481559918 - i}, encode=False))]
print(f"raw signatures containing '+': {len(plus)} of 1000")
ok = sign({"timestamp": now})
for label, b, query, key in [
("correct", base, ok, API_KEY),
("not percent-encoded", base, plus[0], API_KEY),
("other private key", base, sign({"timestamp": now}, Ed.generate()), API_KEY),
("param added after signing", base, "recvWindow=5000&" + ok, API_KEY),
("signature sent twice", base, ok + "&signature=x", API_KEY),
("empty signature", base, f"timestamp={now}&signature=", API_KEY),
("unknown API key", base, ok, "B" * 64),
("wrong base URL", base + "/x", ok, API_KEY),
("nothing listening", "http://127.0.0.1:9", ok, API_KEY),
]:
try:
print(f"{label}: OK {account(b, query, key)}")
except (Rejected, NetworkDown) as exc:
print(f"{label}: {type(exc).__name__} {exc}")
Output:
raw signatures containing '+': 753 of 1000
correct: OK {'canTrade': True}
not percent-encoded: Rejected HTTP 400 -1022 Signature for this request is not valid.
other private key: Rejected HTTP 400 -1022 Signature for this request is not valid.
param added after signing: Rejected HTTP 400 -1022 Signature for this request is not valid.
signature sent twice: Rejected HTTP 400 -1101 Duplicate values for a parameter detected.
empty signature: Rejected HTTP 400 -1105 Parameter 'signature' was empty.
unknown API key: Rejected HTTP 401 -2015 Invalid API-key, IP, or permissions for action.
wrong base URL: Rejected HTTP 404 - <html>not found</html>
nothing listening: NetworkDown ConnectionError
Form decoding turns a + into a space, so in the mock, skipping percent-encoding fails only sometimes: 753 of 1,000 signatures here contained one (73.4% expected). Network failures raise NetworkDown; any reply that is not a 200 JSON object raises Rejected, read with .get(). The mock's 400 for -1022 is its own choice; the table gives real testnet statuses.
Binance error codes for Ed25519 requests
Most Ed25519 failures on Binance show up as one of seven codes.
| Code | Exact message | Testnet status | Ed25519 causes to check |
|---|---|---|---|
| -1021 | Timestamp for this request is outside of the recvWindow. | 400, even with an unknown key | Clock drift or a late send |
| -1022 | Signature for this request is not valid. | Needs a registered key | Base64 not percent-encoded; hex instead of Base64; payload changed after signing; wrong private key |
| -1101 | Duplicate values for a parameter detected. | 400; the text names the parameter | A retry that appends a second signature |
| -1102 | Mandatory parameter '%s' was not sent, was empty/null, or malformed. | 400 | No signature parameter at all |
| -1105 | Parameter '%s' was empty. | 400 | An empty signature, for example from a failed openssl dgst |
| -2014 | API-key format invalid. | 401 | Missing header or a malformed (for example truncated) key |
| -2015 | Invalid API-key, IP, or permissions for action. | 401 | Unknown key, testnet key on production, IP not allowed, missing permission, deleted key |
The wider list is in Binance API error codes.
How to move a running bot from HMAC to Ed25519
Run both keys behind a configuration flag, and delete the HMAC key only after the Ed25519 key has carried production traffic.
- List every program using the HMAC key.
- Create the Ed25519 key with the same permissions and IP restrictions, on the server that signs.
- Put both signers behind one interface; deploy with HMAC still selected.
- Test on the Spot Testnet, then with a read-only production call such as
GET /api/v3/account. - Switch and watch for -1022, -1021 and -2015. Rollback is flipping the flag back.
- After a period set in advance, delete the HMAC key and purge its secret from stores and backups.
WebSocket clients may then move to session.logon, compared in the user data stream guide.
Which exchanges accept Ed25519 API keys
Of seven venues checked on 5 November 2026, Backpack Exchange, Binance and Deribit document Ed25519 keys, Bybit, Kraken and OKX do not mention them, and Coinbase's documents disagree.
| Venue | What the docs say | Public key format | Signature encoding |
|---|---|---|---|
| Backpack Exchange | Signed endpoints require an ED25519 key pair | Raw 32 bytes, Base64, in X-API-Key | Base64 in X-Signature |
| Binance | Ed25519 recommended; RSA; HMAC deprecated, no date | PEM | Base64, percent-encoded on REST |
| Bybit (V5) | Self-generated keys use RSA; Ed25519 not mentioned | – | – |
| Coinbase (Coinbase App APIs) | Auth page: not supported, use ECDSA. Official Python SDK README: recommended | SDK: PKCS#8 PEM or raw Base64 | JWT, EdDSA or ES256 per the SDK |
| Deribit | Ed25519 recommended; RSA from 2048 bits | Full PEM with header and footer | URL-safe Base64 without padding |
| Kraken (Spot REST) | HMAC-SHA512; Ed25519 not mentioned | – | – |
| OKX (API v5) | HMAC SHA256; Ed25519 not mentioned | – | – |
The algorithm is shared; key format, signed string and encoding are not. The Coinbase conflict was not tested with a real key. For integration work in general, see exchange API integration; naming Binance, Backpack, Deribit or any other venue here does not mean Fincode integrates it or has built on it.
Frequently asked questions
Is Binance removing HMAC API keys?
Its key types page (last modified 30 October 2026) calls HMAC keys deprecated but gives no date, and the changelog, read 5 November 2026, announces none.
Why does openssl dgst fail with my Ed25519 key?
Ed25519 signs the message, not a digest, so OpenSSL 3.2.1 rejected openssl dgst -sign. Use openssl pkeyutl -sign -rawin, then Base64.
Can I use session.logon with an HMAC or RSA key?
No, Binance documents it as Ed25519 only. For the user data stream with other key types, use userDataStream.subscribe.signature.
Is it safe to generate an Ed25519 key on a website?
No: the page that creates the private key could keep a copy. Generate real keys offline with OpenSSL, Python or Binance's desktop generator.
Sources
- Binance Spot API documentation: API Key Types (HMAC deprecated, Ed25519 recommended; last modified 30 October 2026) (opens in a new tab)
- Binance Spot API documentation: REST API, request security and Ed25519 signing steps (last modified 30 October 2026) (opens in a new tab)
- Binance Spot API documentation: WebSocket API (Ed25519 example, session authentication, session.logon) (opens in a new tab)
- Binance Spot API documentation: Error codes (opens in a new tab)
- Binance Spot API documentation: Changelog (last updated 30 October 2026) (opens in a new tab)
- Binance Support: How to Generate an Ed25519 Key Pair to Send API Requests on Binance (published 11 July 2023) (opens in a new tab)
- GitHub: binance/asymmetric-key-generator, README and release v1.1.8 (2 November 2026) (opens in a new tab)
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), section 7.1 test vectors (opens in a new tab)
- Backpack Exchange API documentation: Authentication and signing (opens in a new tab)
- Coinbase Developer Documentation: Coinbase App API key authentication (key type requirement) (opens in a new tab)
- GitHub: coinbase/coinbase-advanced-py README (official Python SDK, key types) (opens in a new tab)
- Deribit API documentation: Asymmetric API Keys (opens in a new tab)
- Bybit V5 API: Integration guidance (HMAC and RSA keys) (opens in a new tab)
- Kraken API documentation: Spot REST authentication (opens in a new tab)
- OKX API v5 documentation: REST authentication (opens in a new tab)
This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the editorial policy.