Skip to content

Security Model

The @tokenflight/fiat SDK is built to fail closed. Misconfiguration refuses to boot, the sealed quote token is treated as integrity-only, and every security-relevant fact is re-authorized at order time — after the token is unsealed, before any external side effect.

This page documents that model and, just as importantly, draws an explicit boundary around what the SDK does not do so you can put the right controls in front of it.

Fail-closed by design

createFiat validates its configuration synchronously and throws before returning a handler if the deployment is unsafe. There is no degraded mode — a bad config crashes boot instead of silently serving an open endpoint.

Three guards run at construction time:

GuardConditionResult
Provider listconfig.providers.length === 0CONFIGURATION_ERROR — "at least one provider is required"
CORS policyconfig.cors.origins contains "*"CONFIGURATION_ERROR — "wildcard CORS ('*') is not allowed on provider-backed routes"
Quote secretneither config.quoteState nor config.quoteSecret is setCONFIGURATION_ERROR — "quoteSecret or quoteState is required"
ts
import { createFiat } from "@tokenflight/fiat";

const fiat = createFiat({
  providers: [transak], // empty array -> boot throws
  cors: { origins: ["https://app.example.com"] }, // "*" -> boot throws
  quoteSecret: env.TOKENFLIGHT_FIAT_SECRET, // omit both -> boot throws
  state,
});

Order time re-authorizes everything

The sealed quote token proves only that your service issued this quote and it has not been tampered with. It carries no authorization. So POST /order independently re-checks every security-relevant fact after unsealing and before the first side effect (settlement prep, provider order creation, state write):

Re-check at order timeSourceFailure
Provider not disabledruntime.disabledProviders.has(payload.provider)PROVIDER_DISABLED (403)
Provider context requirementsassertProviderRequirements(...)CONTEXT_REQUIRED (400)
Recipient valid for target chainvalidateRecipient or built-in checkVALIDATION_ERROR (400)
Asset still in the allowlistassertAssetSupported(...)UNSUPPORTED_ASSET (422)
Fiat amount within limitsassertAmountWithinLimits(...)VALIDATION_ERROR (400)
Rate limit not exceededassertWithinRateLimit(...)RATE_LIMITED (429)

Most of these are also enforced at quote time for defense-in-depth, but the order-time pass is the authoritative one — it runs even if the quote was issued under a now-changed policy.

CORS: a browser-origin allowlist, not authentication

CORS is configured with a required allowlist. Wildcards are unrepresentable: assertValidCorsPolicy rejects "*" at boot.

ts
cors: {
  origins: ["https://app.example.com", "https://checkout.example.com"];
}
  • An allowed origin is echoed back exactly — never "*" — with Access-Control-Allow-Credentials: true and Vary: Origin.
  • An empty list is valid and means deny-all for browsers: no Origin is allowed, but server-to-server requests (which carry no Origin header) still pass. This lets an unconfigured deploy boot rather than crash.
  • A browser request from an origin not on the list is rejected with ORIGIN_NOT_ALLOWED (403).

Preflight (OPTIONS) responses are emitted directly from the CORS layer:

text
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Headers: content-type,idempotency-key,x-integrator-id
Access-Control-Max-Age: 600

CORS is not authorization

The webhook (/webhook/{provider}) and widget (/widget/{orderId}) routes are not browser routes and are not subject to the CORS allowlist — webhooks are server-to-server and the widget is framed cross-origin by design. CORS controls which browser pages may call the API; it does not authenticate end users. See What the SDK does not do.

Quote sealing: integrity, not authorization

A quote id is an opaque, AEAD-encrypted, self-contained handle. The default sealed strategy keeps zero server state — the token is the quote.

The token format is:

text
fq1.<kid>.<nonce-b64url>.<ciphertext-b64url>
  • fq1 is the version tag; <kid> is the key id (empty by default).
  • The plaintext is the minimal JSON needed to recreate and validate the order.
  • Encryption is AES-256-GCM. The Additional Authenticated Data binds the version and key id (fq1.<kid>), so a token cannot be replayed under another scheme or key id.

This is an integrity mechanism only. A structurally valid, correctly authenticated token does not imply an authorized order — that is precisely why POST /order re-validates everything (see Order time re-authorizes everything). Quote-token validation failures map to:

ConditionErrorHTTP
Malformed token / wrong version / bad JSON / failed GCM authQUOTE_INVALID400
Key id is unknown or revokedQUOTE_REVOKED401
Quote past expiry (+ grace)QUOTE_EXPIRED409

A sealed token has a hard size ceiling. The bundled strategy enforces SEALED_TOKEN_HARD_LIMIT (2048 chars; override with maxTokenLength) at issue time and throws QUOTE_TOO_LARGE (413) when exceeded — the signal to switch to a server-side store strategy. (SEALED_TOKEN_SOFT_LIMIT is 512, a design budget rather than an enforced bound.)

Secret management & rotation

The quote secret type is:

ts
type AeadSecret = string | Uint8Array | CryptoKey;
  • A string or Uint8Array is HKDF-stretched (SHA-256) into a uniform AES-256-GCM key, so an arbitrary-length or lower-entropy secret becomes a proper key.
  • A CryptoKey is used directly.

Supply the secret only from config or environment — never hard-code it, and never expose it to the browser.

ts
const fiat = createFiat({
  // ...
  quoteSecret: env.TOKENFLIGHT_FIAT_SECRET, // string from a Worker secret
});

Rotation invalidates outstanding tokens

The shipped sealed strategy resolves exactly one active key id:

ts
unsealQuote(quoteId, (tokenKid) => (tokenKid === kid ? config.secret : undefined));

There is no overlap window. Consequences when you rotate:

  • Change the secret, keep the same kid → existing tokens fail GCM authentication → QUOTE_INVALID (400).
  • Change/add the kid so the old id no longer resolves → existing tokens hit the unknown-key branch → QUOTE_REVOKED (401).

Either way, every outstanding quote token is invalidated immediately. Users mid-flow must request a fresh quote.

Zero-downtime rotation workaround

The unsealQuote building block accepts an arbitrary resolveSecret(kid) callback that can map multiple key ids to multiple secrets — but QuoteStateConfig only wires the single-kid sealed strategy or a bring-your-own store strategy. For rotation without invalidating live tokens, use the store strategy (server-side payloads keyed by a random fq_ id, where you control the lifecycle), or build a custom strategy around the exported sealQuote / unsealQuote with a multi-kid resolver.

Trusted request context: IP & origin

The end-user IP is never taken from a client-supplied header like X-Forwarded-For or x-user-ip. It comes only from:

  1. the transport-level connection IP (transport.clientIp), or
  2. a header you explicitly trust via trustProxy.userIpHeaders.

The default is Cloudflare's connection header:

ts
trustProxy: {
  userIpHeaders: ["cf-connecting-ip"];
} // default when omitted

The first value of the first matching trusted header is used; the Origin, country (cf-ipcountry), user agent, and locale are likewise read from fixed, trusted positions.

Not behind Cloudflare?

If you are not deploying behind Cloudflare, configure trustProxy.userIpHeaders to match your proxy's real-client-IP header (or supply transport.clientIp from your platform adapter). Otherwise endUserIp is empty — and providers that require it will fail closed.

Provider requirements fail closed

Each provider declares what end-user context it needs. If a field is marked "required" and missing from the trusted context, the call is rejected with CONTEXT_REQUIRED (400) before the provider is ever contacted:

ts
interface ProviderRequirements {
  origin?: "required" | "optional" | "unused";
  endUserIp?: "required" | "optional" | "unused";
  country?: "required" | "optional" | "unused";
  email?: "required" | "optional" | "unused";
}

For example, the Transak adapter declares:

ts
requirements: { origin: "required", endUserIp: "required", country: "optional" }

so an order missing a trusted origin or end-user IP cannot reach Transak.

Order-time guardrails

Because the sealed token is integrity-only, these integrator policies are all (re-)applied at order time:

ControlConfig fieldBehavior
Provider kill-switchdisabledProviders: string[]Listed providers are rejected fail-closed at both quote and order time (PROVIDER_DISABLED, 403).
Strict recipient checkvalidateRecipient(address, chainId)Overrides the permissive built-in structural check. Returning false raises VALIDATION_ERROR.
Hard fiat boundslimits: { minFiatAmount?, maxFiatAmount? }Enforced independent of the provider's own limits (VALIDATION_ERROR).
Rate limiterrateLimiterInterface-only — bring your own implementation. Rejection raises RATE_LIMITED (429).
Metadata size cap(built-in)Integrator metadata must be JSON-safe and at most 4096 bytes serialized (VALIDATION_ERROR).

The rate limiter is keyed only on trusted request context — origin, end-user IP, and recipient — never on client-forged values.

ts
interface FiatRateLimiter {
  check(input: {
    origin?: string;
    endUserIp?: string;
    recipient?: string;
  }): Promise<{ allowed: boolean; retryAfterSeconds?: number }> | { allowed: boolean; retryAfterSeconds?: number };
}

Webhook security

Webhook handling is verify-before-parse:

  • The raw body is passed in unparsed so the provider adapter can verify the signature against the exact bytes received. The SDK never parses or re-serializes the body ahead of verification. An unverified or ignorable event returns outcome: "rejected".

  • The HTTP status your endpoint returns is the retry contract. A provider retries on 5xx and stops on 2xx/4xx — with one deliberate exception: unknown_order returns 404 but is meant to be retried (see the next bullet). The standard route maps outcomes accordingly:

    OutcomeHTTPProvider behavior
    accepted / duplicate200stop
    rejected400stop
    unknown_order404retry (deliberate)
    unexpected/transient failure500retry
  • Deferred dedup prevents losing an event that races order creation. The SDK does not record a dedup receipt for an order it cannot yet resolve — otherwise a webhook arriving before the create-order commit would be remembered as "seen" and the provider's retry dropped as a duplicate. Without the receipt, the retry re-runs the lookup and succeeds once the order exists.

See Lifecycle & webhooks for the full event flow and status table.

What the SDK does not do

This is payments infrastructure. The SDK enforces a tight, fail-closed perimeter around quoting and ordering — but several controls are explicitly your responsibility:

  • End-user authentication and authorization. CORS is a browser-origin control, not auth. Anyone who can reach POST /order with a valid quote and a permitted Origin can create an order. Put your own authentication and authorization in front of POST /order (session check, signed request, API gateway — whatever fits your app).
  • A rate-limiter implementation. The SDK ships the FiatRateLimiter interface and the call site only. You supply the backing store (e.g. a KV or Durable Object token bucket). With no limiter configured, there is no rate limiting.
  • A persistent quote store. The sealed strategy is stateless; the store strategy is interface-only. The SDK ships no server-side quote-store implementation — bring your own FiatQuoteStateStore adapter if you need one.
  • TLS termination. Run behind HTTPS; the SDK assumes the transport is already secure.
  • Secret storage and rotation infrastructure. The SDK consumes a secret; it does not store, manage, or rotate it. There is no overlap window for rotation (see above).
  • Abuse, fraud, and anomaly monitoring. No fraud scoring, velocity analysis, or alerting is included. Wire your own observability and abuse detection around the provided hooks.

See also

  • Configuration — wiring createFiat into your own Worker or server, and supplying state/rate-limit/quote-store adapters.
  • Lifecycle & webhooks — order status flow, webhook delivery, and retries.
  • Jump routes — how multi-hop settlement (ODA + Hyperstream) interacts with order-time validation.
  • API reference — full request/response schemas and the error envelope.