Exchange API Authentication Compared: API Key vs JWT vs HMAC-SHA256

🌐 한국어

What eats your first few days on an exchange API integration usually isn't strategy—it's 401 Unauthorized. You followed the docs, it doesn't work, and the server won't tell you what's wrong. Most of the time the cause is copy-pasting an example without understanding the structure of the authentication scheme. This article separates the three schemes actually in use, starting from first principles.

⚠️ This article is for educational and informational purposes. It is not investment advice, and you alone are responsible for any losses. Every key value in the code below is an example placeholder.

Why Are There Different Schemes?

Authentication is solving two problems.

  • Identity — whose account is this request for?
  • Tamper resistance — did someone swap the quantity or price in transit?

A quote lookup only needs the first, so one key in a header finishes the job. An order requires the second, so you build a signature out of the request content itself and send it along. Only someone holding the secret key can produce that signature, and it changes completely if a single character of the request changes.

Scheme 1 — API Key Header (Simplest)

You put the key straight into a header. There's no signature, so you use it for read-only work like public quotes and balance lookups, or as the "identity" half of an order API.

headers = {"X-MBX-APIKEY": API_KEY}     # example placeholder

r = requests.get(url, headers=headers, timeout=(3, 10))

The upside is obvious—five minutes to implement. So is the downside. The key travels as-is, so a leak is game over, and it does nothing to prevent tampering with the request. Never use it without HTTPS, and as a baseline, turn withdrawal permissions off at the exchange and put an IP allowlist on the key.

Scheme 2 — HMAC Signature (Binance and Friends)

HMAC (Hash-based Message Authentication Code — a hash mixed with a secret key) builds a fingerprint from "the content you're sending + the secret key" and attaches it. The secret key itself never goes over the network.

Binance's procedure runs like this.

  1. Assemble the parameters into a query string
  2. Append timestamp (milliseconds) at the end
  3. Sign that entire assembled string with HMAC-SHA256 → a hex string
  4. Append it as signature=, and put the API key in a header
import hmac, hashlib, time

from urllib.parse import urlencode

def signed_query(params: dict, secret: str) -> str:

    params["timestamp"] = int(time.time() * 1000)

    query = urlencode(params)                       # sign exactly this string

    sig = hmac.new(secret.encode(), query.encode(),

                   hashlib.sha256).hexdigest()

    return f"{query}&signature={sig}"

Go is identical.

func sign(query, apiSecret string) string {

    mac := hmac.New(sha256.New, []byte(apiSecret))

    mac.Write([]byte(query))

    return hex.EncodeToString(mac.Sum(nil))

}

// req.Header.Set("X-MBX-APIKEY", apiKey)

The single most common mistake here is that "the string you signed" and "the string you actually sent" diverge. Reorder parameters after signing, re-encode, or re-sort the dictionary and the signature is instantly invalid. Send exactly the string you signed.

timestamp goes into the signed material to prevent replay attacks. Servers typically reject requests outside a recvWindow of roughly five seconds. That means a clock more than five seconds off from the server keeps failing. Clock drift on a VM is routine, so NTP sync isn't optional.

Worth noting: even among HMAC schemes, "what gets signed" varies by exchange. Bithumb, for example, signs endpoint + \x00 + parameters + \x00 + nonce with SHA512, then converts it to hex and base64-encodes that string again. Missing the double encoding and chasing a 401 for days is a genuinely common experience. And the nonce in the header must be the exact same value used in the signature — fetch the time a second time for the header and it breaks right there.

Scheme 3 — JWT (Upbit and Friends)

A JWT (JSON Web Token) is "a JSON blob of auth information, signed into a single token." The cryptographic principle is the same as HMAC (HS256 = HMAC-SHA256), but what gets signed is a JSON payload rather than a query string.

Upbit composes the payload like this.

import jwt, uuid, hashlib

from urllib.parse import urlencode

def upbit_token(access_key, secret_key, params: dict | None = None):

    payload = {

        "access_key": access_key,

        "nonce": str(uuid.uuid4()),      # unique per request

    }

    if params:

        query = urlencode(params)

        payload["query_hash"] = hashlib.sha512(query.encode()).hexdigest()

        payload["query_hash_alg"] = "SHA512"

    return "Bearer " + jwt.encode(payload, secret_key, algorithm="HS256")

# headers = {"Authorization": upbit_token(ACCESS_KEY, SECRET_KEY, params)}

The structure reveals the design intent. Instead of signing the parameters wholesale, only a hash of them (query_hash) goes into the payload. That cleanly separates auth information from request body, and lets you omit query_hash entirely on lookups that carry no parameters.

Two traps live here.

  • query_hash must be built from a string 100% identical to the query you actually send. Libraries differ in how they encode array parameters (uuids[]=a&uuids[]=b and variants), and this is a frequent source of mismatches.
  • nonce must be regenerated on every request. Reuse it and you're rejected.

Bonus — OAuth Token Flow (Korean Brokerage APIs)

There's a fourth pattern with a different character. You obtain a token first using an app key and app secret, then authenticate subsequent requests with that token.

payload = {

    "grant_type": "client_credentials",

    "appkey": APP_KEY,           # example placeholder

    "appsecret": APP_SECRET,

}

# POST /oauth2/tokenP → receive access_token (lifetime around 24 hours)

headers = {

    "authorization": f"Bearer {access_token}",

    "appkey": APP_KEY,

    "appsecret": APP_SECRET,

    "tr_id": tr_id,              # endpoint selector

    "custtype": "P",

}

No per-request signing means simpler code. In exchange you inherit a new chore: token lifetime management. Recording the issue time and checking freshness before each request is the safe pattern. Some providers issue a separate key for real-time quotes (an approval key) distinct from the REST token—so if "REST works but the real-time feed won't connect," suspect this first.

At a Glance

  • API key header — five minutes to build / no tamper resistance / suitable for read-only
  • HMAC signature — secret never transmitted / full request tamper resistance / string equality is everything / clock sync mandatory
  • JWT — auth separated from body / stable as long as query_hash is exact / fresh nonce every time
  • OAuth token — simplest request code / needs expiry and refresh logic

What to Check When You Get a 401

  1. Clock — more than five seconds off the server? (the number-one cause on HMAC schemes)
  2. The signed string — log the string you signed and the string you sent, then compare them by eye
  3. Encoding — hex or base64, and is double encoding required?
  4. Whitespace in the key — a trailing newline or space when read from .env or a config file. A surprising number of cases are fixed by one .strip()
  5. Permissions and IP — does that key have trading permission in the exchange console, and is your server IP on the allowlist?
  6. Testnet vs production mix-up — do the endpoint and the key actually match?

Key Storage — Non-Negotiable

# ❌ never

API_KEY = "abcd1234..."          # hardcoded in source → immortalized in git

# ✅ environment variable first, config file as fallback

import os

API_KEY = os.getenv("EXCHANGE_API_KEY") or cfg.get("api", "key", fallback="")

API_KEY = API_KEY.strip()
  • Config files always go in .gitignore. Check once more with git diff before committing
  • If you commit one by accident, revoking and reissuing the key at the exchange comes before scrubbing history
  • Keep withdrawal permission off by default and grant only the minimum needed
  • Never log keys, signatures, or tokens. If you're mid-debug, mask everything but the first four characters

Authentication has nothing to do with how well your strategy performs, but get stuck here and you never get the chance to test the strategy at all. Understand the structure of each scheme once and connecting a new exchange drops from days to about an hour.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기