Security

Auth model

Two distinct authentication paths live in BabyFX Pay. This page explains both, when each is used, and what each protects.

At a glance

Path Who What it protects
Bearer api_key Merchants calling pay/api Invoice creation, merchant configuration
HMAC-SHA256 signature pay/webhooks calling merchant Webhook authenticity
JWT (internal) pay/api ? pay/settlement ? pay/sweep Internal RPC between pay services
No auth (open) Public read endpoints Read-only data, rate-limited

1. Merchant ? pay/api (Bearer api_key)

Every state-changing call from a merchant carries Authorization: Bearer bfx_....

POST /v1/invoices
Authorization: Bearer bfx_0123456789abcdef0123456789abcdef0123456789abcdef
Content-Type: application/json
Idempotency-Key: pos-2026-06-22-1234

{ "fiat_amount": "4.50", "fiat_currency": "USD" }

Server-side

  1. Parse Authorization header.
  2. Look up the api_key_hash (Argon2id) in the merchants table.
  3. Constant-time compare.
  4. On match, attach the merchant to the request context.
  5. On miss, return 401 with a generic message (no oracle for "valid key prefix").
Why Argon2id and not bcrypt or scrypt? Argon2id is memory-hard, GPU-resistant, and the OWASP-recommended default for new applications in 2026. The cost parameters are tuned so a single verify takes ~250ms on the host''s CPU.

Rotation

api_key is shown once at merchant creation and never again. To rotate:

  1. GET /v1/merchant to find the merchant id.
  2. DELETE /v1/merchant (soft-delete) to invalidate the old key.
  3. POST /v1/merchants again to get a new key.

The webhook_secret is rotated the same way.

2. pay/webhooks ? Merchant (HMAC-SHA256)

Webhooks are signed with the merchant''s webhook_secret. The signed string is ${timestamp}.${rawBody}. See Webhooks for the full spec and verification code.

Always verify the timestamp AND the signature. A captured-but-old signature is a replay attack.

3. pay/* internal (JWT)

The three pay/ services (api, settlement, sweep, webhooks) talk to each other over an internal HTTP bus. Calls carry a short-lived JWT (15 min) signed with PAY_INTERNAL_JWT_SECRET. The bus is not exposed to the public internet.

4. Public read endpoints

GET /v1/invoices/{id}?lookup_token=... is open, gated only by possession of a 128-bit lookup_token returned at invoice creation. This is how a customer-facing page (no login) can poll invoice state.

Treat lookup_token as a secret. It is the only credential required to read the invoice. Do not log it. Do not put it in URLs that will be cached or shared.