Skip to main content

HMAC-signed auth

Per-request HMAC signature auth used by Foxbit and other LATAM crypto exchanges — KEY/SECRET fields, validation round-trip, and runtime header injection.

2 min read · updated

HMAC-signed auth

@codespar/api-typesv0.4.0

hmac_signed is the auth shape where every outbound request carries a freshly computed HMAC signature in addition to the public key — the credential is not a static bearer token but a per-call cryptographic proof that the caller knows the shared secret and the request body has not been tampered with.

This pattern is preferred by exchanges and any provider that wants replay resistance and tight binding between a key pair and the exact endpoint a request hits. The signature covers the timestamp, HTTP method, path, and body, so a captured request cannot be replayed against a different endpoint or after the timestamp window expires. It sits alongside api_key, path_secret, oauth, cert, and none in the six-type taxonomy.

When you'll use it

  • Foxbit — production today. Brazilian crypto exchange. Trading + wallet endpoints.
  • Future LATAM crypto exchanges that follow the same pattern via catalog overrides — Coinbase Pro, Kraken, and similar all use HMAC-signed APIs that fit the runtime once their per-provider header names and signing recipe are captured in the catalog row.

The defining property of these providers: they hand you a two-piece credential (a public access key and a private secret) and document a signing algorithm that mixes the secret with each request's data.

Field shape

The operator stores two values in the vault:

FieldVisibilityWhat it is
access_keyVisible (last-4 in the dashboard)Public identifier of the key pair. Sent in cleartext on every request. Foxbit calls this KEY.
secretMasked entirelyPrivate signing secret. Never sent on the wire. Used only to compute the HMAC. Foxbit calls this SECRET.

The dashboard's connect modal renders KEY/SECRET badges next to each input — visible-vs-masked is driven by the catalog row's per-field metadata, not hardcoded per provider, so a future provider that uses different terminology (api_key/api_secret, etc.) reuses the same component.

Connect flow

  1. Operator opens /dashboard/auth-configs.
  2. Picks the hmac_signed-typed server — for example, foxbit.
  3. The Provider Connect modal renders two fields with the right per-field badges (KEY visible, SECRET masked).
  4. Operator pastes both values from the provider's developer portal.
  5. Operator clicks Validate. The dashboard fires POST /v1/connections/hmac-validate (admin-key route), which:
    • Computes a signature for a noop / echo request using the pasted secret.
    • Sends that signed request to the provider's echo or healthz endpoint.
    • Returns { ok: true } on a 2xx — or the provider's exact error code on failure (e.g. 401 signature mismatch, 401 key invalid).
  6. On a green response, operator clicks Save and both fields are persisted to the vault. On a red response, the dashboard surfaces the upstream error inline so the operator can fix the input before persisting anything.

Why validate before persist? A bad KEY/SECRET pair would otherwise sit silently in the vault until the first real session.execute() call, where the failure surfaces as a far less specific runtime error. The validation round-trip is fast, free, and trades one extra dashboard click for a clean operator UX.

What happens at runtime

On every outbound call to an hmac_signed provider, the proxy executor:

  1. Pulls access_key and secret from the vault.
  2. Builds the canonical signing string per the catalog row's recipe — for Foxbit that is timestamp + method + path + body, where timestamp is unix milliseconds.
  3. Computes signature = hmac_sha256(secret, signing_string).
  4. Stamps the required headers onto the outbound request. For Foxbit: X-FB-API-KEY, X-FB-API-TIMESTAMP, X-FB-API-SIGNATURE. For other providers, the header names are different — the catalog row records them.
session.execute("foxbit/order_create", { side: "buy", ... })


proxy-executor.ts

  ├─ pull { access_key, secret } from vault
  ├─ ts = Date.now()
  ├─ signing_string = `${ts}${method}${path}${body}`
  ├─ sig = hmac_sha256(secret, signing_string)
  ├─ headers["X-FB-API-KEY"]       = access_key
  ├─ headers["X-FB-API-TIMESTAMP"] = ts
  ├─ headers["X-FB-API-SIGNATURE"] = sig

HTTPS request — provider verifies signature against the same recipe


2xx → normalized response       4xx → bubbled up with provider error code

The signature is recomputed on every request — there is no token to refresh, no session to extend. Replay protection comes from the timestamp: if the provider's clock and ours diverge by more than the provider's tolerance (typically 5 seconds), the request is rejected.

Catalog spec snippet

A minimal Foxbit catalog row declares the auth shape and the per-provider header mapping:

{
  "id": "foxbit",
  "name": "Foxbit",
  "auth_type": "hmac_signed",
  "secrets": [
    { "name": "access_key", "kind": "key",    "label": "API key",    "visibility": "visible" },
    { "name": "secret",     "kind": "secret", "label": "API secret", "visibility": "masked"  }
  ],
  "hmac": {
    "algorithm": "sha256",
    "signing_string": "${timestamp}${method}${path}${body}",
    "headers": {
      "key":       "X-FB-API-KEY",
      "timestamp": "X-FB-API-TIMESTAMP",
      "signature": "X-FB-API-SIGNATURE"
    },
    "timestamp_unit": "ms"
  }
}

The hmac block is the per-provider knob: change the header names, the concatenation order, the timestamp unit, or the algorithm and you've onboarded a different exchange without touching the runtime. Adding Coinbase Pro is a catalog drop, not a backend change.

Provider variations

Different exchanges signed the spec slightly differently — there is no industry standard, just a common shape:

  • Header names vary widely. Foxbit prefixes with X-FB-; Coinbase Pro uses CB-ACCESS-*; Kraken uses API-Key + API-Sign.
  • Signing-string concatenation order differs. Some providers put the body first, some put the path last, some include a passphrase. The catalog row's signing_string template captures the exact order.
  • Timestamp units can be seconds or milliseconds — and a few use the Date HTTP header instead of a custom one.
  • Algorithm is almost always SHA-256, but a couple use SHA-512.

The runtime never assumes; everything is read off the catalog row. When onboarding a new provider, the work is reading their docs carefully, transcribing the recipe into the catalog spec, and confirming with one validation round-trip.

Troubleshooting

SymptomLikely causeFix
401 signature mismatch on every callClock skew (server time off > provider's tolerance) or wrong concatenation order in the catalog specCheck NTP on the proxy host. Re-read the provider's docs and confirm signing_string matches their pseudocode exactly.
401 key invalid immediately after SaveThe KEY was pasted with a leading/trailing whitespace, or it was minted in a different environment (test vs prod) than the one being calledRe-mint or re-copy the key. Confirm the dashboard environment toggle (Live/Test) matches where the key is valid.
Validate round-trip returns red, but Save would have succeededAlmost never — the validation endpoint uses the same signing path as the runtimeTrust the red. Read the surfaced error code; it's the provider's exact response.
Calls work for a few minutes then start failing 401 signature mismatchClock drift on the proxy host — common on virtualized infra without NTPEnable NTP. Until then, intermittent 401s will keep happening.

Next steps

Edit on GitHub

Last updated on