How do you authenticate a trading bot safely with API keys?
If you have integrated one exchange API you have integrated most of them. The signing scheme is near-universal: a key id, a secret, an HMAC over some canonical form of the request.
What varies — and what almost no documentation explains — is exactly which bytes go into the signature, what else the server checks, and in what order. Those details are the security. The HMAC is the easy part.
What actually gets signed
BT365's trading API follows the Binance and Hyperliquid convention. A request carries the key id, a client timestamp, and a hex HMAC-SHA256 signature, with an optional field for how much clock skew you want tolerated.
The signed material is the part worth being precise about:
${timestamp}\n${METHOD}\n${path}\n${rawBody}
Four components, each there for a reason.
The timestamp binds the request to a moment, which is what makes the replay window enforceable. The method stops a signature for a GET being replayed as a DELETE on the same path. The path stops a signature for one endpoint being reused on another. And the raw body — the exact bytes, not a re-serialised version of the parsed object — is what stops an attacker altering the payload while keeping the signature intact.
That last one is the subtle bug. If a server parses JSON and then re-serialises it to verify, two different byte sequences can produce the same canonical form, and key ordering or whitespace differences can make a legitimate signature fail or an altered body pass. Sign and verify the bytes that arrived.
The order of the checks is the design
A signature check is not the first thing that should happen, and treating it as the only thing is where implementations go wrong. Ours runs in this order:
- Resolve the key. Unknown key id, done.
- IP allowlist, if the key has one. Cheap, and it makes a stolen key useless from anywhere else.
- Per-key rate limit, in Redis. Before any cryptography.
- Timestamp window. Default five seconds, configurable up to sixty.
- Replay rejection — has this exact signature been seen before?
- Recompute and compare the HMAC, in constant time.
- Scope check — is this key allowed to do this?
Two things about that ordering.
The cheap rejections come first, deliberately. An HMAC is inexpensive but not free, and neither is the database lookup behind a scope check. Putting the rate limit ahead of the cryptography means a flood of garbage requests costs a Redis increment each rather than a hash and a query each. Authentication is the most exposed surface you have; it should be the cheapest thing to fail.
The replay check and the timestamp window are not redundant. The window bounds how long a captured request stays usable. The replay check means it cannot be used even twice inside that window. Neither alone is sufficient: without the window you would have to remember every signature forever, and without the replay check a request captured and resent within the same few seconds is perfectly valid. Together they give you a bounded memory and no reuse.
The comparison being constant-time matters for the same reason it does anywhere else — a byte-by-byte compare that returns early leaks, through timing, how much of a guessed signature was correct, which turns forgery into a series of cheap measurements instead of an impossible search.
Scopes, because a leaked key is a question of degree
Keys carry one of three scopes: read, trade, or withdraw.
The reason to separate them is that the consequences are not comparable. A leaked read key exposes your positions — bad. A leaked trade key lets someone move your money around inside your account — worse. A leaked withdrawal key lets them take it — final. Issuing one key that can do all three, for a bot that only needs to place orders, converts a recoverable incident into an unrecoverable one for no benefit.
The practical advice is unglamorous and worth following: give a strategy a trade-scoped key, never a withdrawal-scoped one, and pin it to the IP it runs from. If the strategy is on a fixed host, the allowlist costs you nothing and removes almost the entire value of stealing the key.
The secret at rest, and the one-time reveal
Two implementation details that are easy to get wrong in the other direction.
The secret is encrypted at rest with AES-256-GCM under a dedicated key, not stored in plaintext and not merely hashed. This differs from a password on purpose: the server has to be able to recompute an HMAC with the original secret, so a one-way hash would not work. Encryption is the correct tool, and the encryption key belongs in the environment, never in the database beside the ciphertext.
The secret is shown once, at creation. There is no endpoint that returns it later. That is not an inconvenience to work around — it is the property that means a compromised admin session cannot enumerate every customer's signing secrets.
Failure messages tell you nothing
Every rejection above returns a generic message. Not "unknown key", not "signature mismatch", not "timestamp too old".
This is deliberate and it is occasionally annoying to integrate against. The reason is that specific auth errors are an oracle: "unknown key" versus "bad signature" tells an attacker which key ids are real, and "timestamp outside window" versus "bad signature" tells them their signing is correct and only their clock is off. Each distinction hands over a piece of the puzzle.
When you are integrating and getting a generic rejection, work through the list above in order — key id, source IP, clock, then signed material. It is almost always the raw body or the clock.
Part of our guide to making an API usable by AI agents.
BT365 exposes Hyperliquid perps to external bots over an HMAC-signed API with scoped keys. Explore the platform or read the API documentation.