# Ed25519 API key: create, sign and migrate off HMAC

URL: https://fincodelabs.com/en/blog/ed25519-api-key · Publisher: Fincode (Fincode Yazılım ve Teknoloji A.Ş.), Istanbul, Turkey · Category: Exchange APIs · Published: 2026-11-06 · Updated: 2026-11-06

Ed25519 API key guide: generate the pair, register the public key, sign Binance requests, fix -1022 and -2015 errors and move a bot off HMAC.

**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.logon` on the WebSocket API. The user data stream does not need it, since `userDataStream.subscribe.signature` (12 August 2025) takes any key type.

Storage is covered in [API key security for trading bots](https://fincodelabs.com/en/blog/api-key-security-trading-bots), key creation in [how to create an exchange API key](https://fincodelabs.com/en/blog/exchange-api-key-how-to).

## How to create an Ed25519 key pair

Generate the pair on the machine that will sign, and encrypt the private key with a passphrase.

```bash
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:

```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&timestamp=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.

1. **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-APIKEY` header; testnet setup is in the [Binance API Python guide](https://fincodelabs.com/en/blog/binance-api-python-trading-bot).
2. **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.
3. **WebSocket API.** Sort every parameter except `signature`, `apiKey` included, by name, join them as `name=value` with `&`, 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):

```python
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.

**Messages from Binance's error codes page; HTTP statuses from the Spot Testnet with a synthetic key; both 5 November 2026**

| 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](https://fincodelabs.com/en/blog/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.

1. List every program using the HMAC key.
2. Create the Ed25519 key with the same permissions and IP restrictions, on the server that signs.
3. Put both signers behind one interface; deploy with HMAC still selected.
4. Test on the Spot Testnet, then with a read-only production call such as `GET /api/v3/account`.
5. Switch and watch for -1022, -1021 and -2015. Rollback is flipping the flag back.
6. 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](https://fincodelabs.com/en/blog/binance-user-data-stream).

## 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.

**Official documentation read 5 November 2026, alphabetical, not a ranking**

| 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](https://fincodelabs.com/en/services/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)](https://developers.binance.com/en/docs/products/spot/faqs/api_key_types)
- [Binance Spot API documentation: REST API, request security and Ed25519 signing steps (last modified 30 October 2026)](https://developers.binance.com/en/docs/products/spot/rest-api#request-security)
- [Binance Spot API documentation: WebSocket API (Ed25519 example, session authentication, session.logon)](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-api.md)
- [Binance Spot API documentation: Error codes](https://github.com/binance/binance-spot-api-docs/blob/master/errors.md)
- [Binance Spot API documentation: Changelog (last updated 30 October 2026)](https://github.com/binance/binance-spot-api-docs/blob/master/CHANGELOG.md)
- [Binance Support: How to Generate an Ed25519 Key Pair to Send API Requests on Binance (published 11 July 2023)](https://www.binance.com/en/support/faq/detail/6b9a63f1e3384cf48a2eedb82767a69a)
- [GitHub: binance/asymmetric-key-generator, README and release v1.1.8 (2 November 2026)](https://github.com/binance/asymmetric-key-generator)
- [RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), section 7.1 test vectors](https://www.rfc-editor.org/rfc/rfc8032.html)
- [Backpack Exchange API documentation: Authentication and signing](https://docs.backpack.exchange/)
- [Coinbase Developer Documentation: Coinbase App API key authentication (key type requirement)](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/api-key-authentication)
- [GitHub: coinbase/coinbase-advanced-py README (official Python SDK, key types)](https://github.com/coinbase/coinbase-advanced-py)
- [Deribit API documentation: Asymmetric API Keys](https://docs.deribit.com/articles/asymmetric-api-keys)
- [Bybit V5 API: Integration guidance (HMAC and RSA keys)](https://bybit-exchange.github.io/docs/v5/guide)
- [Kraken API documentation: Spot REST authentication](https://docs.kraken.com/exchange/guides/rest/authentication)
- [OKX API v5 documentation: REST authentication](https://www.okx.com/docs-v5/en/)

> 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](https://fincodelabs.com/en/editorial-policy).

---

Published by Fincode (Fincode Yazılım ve Teknoloji A.Ş.), a software company in Istanbul, Turkey: https://fincodelabs.com/en/blog/ed25519-api-key

Cite as: Fincode, "Ed25519 API key: create, sign and migrate off HMAC", updated November 6, 2026, https://fincodelabs.com/en/blog/ed25519-api-key
