Appearance
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:
| Guard | Condition | Result |
|---|---|---|
| Provider list | config.providers.length === 0 | CONFIGURATION_ERROR — "at least one provider is required" |
| CORS policy | config.cors.origins contains "*" | CONFIGURATION_ERROR — "wildcard CORS ('*') is not allowed on provider-backed routes" |
| Quote secret | neither config.quoteState nor config.quoteSecret is set | CONFIGURATION_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 time | Source | Failure |
|---|---|---|
| Provider not disabled | runtime.disabledProviders.has(payload.provider) | PROVIDER_DISABLED (403) |
| Provider context requirements | assertProviderRequirements(...) | CONTEXT_REQUIRED (400) |
| Recipient valid for target chain | validateRecipient or built-in check | VALIDATION_ERROR (400) |
| Asset still in the allowlist | assertAssetSupported(...) | UNSUPPORTED_ASSET (422) |
| Fiat amount within limits | assertAmountWithinLimits(...) | VALIDATION_ERROR (400) |
| Rate limit not exceeded | assertWithinRateLimit(...) | 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
"*"— withAccess-Control-Allow-Credentials: trueandVary: Origin. - An empty list is valid and means deny-all for browsers: no
Originis allowed, but server-to-server requests (which carry noOriginheader) 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: 600CORS 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>fq1is 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:
| Condition | Error | HTTP |
|---|---|---|
| Malformed token / wrong version / bad JSON / failed GCM auth | QUOTE_INVALID | 400 |
| Key id is unknown or revoked | QUOTE_REVOKED | 401 |
| Quote past expiry (+ grace) | QUOTE_EXPIRED | 409 |
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
stringorUint8Arrayis HKDF-stretched (SHA-256) into a uniform AES-256-GCM key, so an arbitrary-length or lower-entropy secret becomes a proper key. - A
CryptoKeyis 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
kidso 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:
- the transport-level connection IP (
transport.clientIp), or - a header you explicitly trust via
trustProxy.userIpHeaders.
The default is Cloudflare's connection header:
ts
trustProxy: {
userIpHeaders: ["cf-connecting-ip"];
} // default when omittedThe 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:
| Control | Config field | Behavior |
|---|---|---|
| Provider kill-switch | disabledProviders: string[] | Listed providers are rejected fail-closed at both quote and order time (PROVIDER_DISABLED, 403). |
| Strict recipient check | validateRecipient(address, chainId) | Overrides the permissive built-in structural check. Returning false raises VALIDATION_ERROR. |
| Hard fiat bounds | limits: { minFiatAmount?, maxFiatAmount? } | Enforced independent of the provider's own limits (VALIDATION_ERROR). |
| Rate limiter | rateLimiter | Interface-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
5xxand stops on2xx/4xx— with one deliberate exception:unknown_orderreturns404but is meant to be retried (see the next bullet). The standard route maps outcomes accordingly:Outcome HTTP Provider behavior accepted/duplicate200 stop rejected400 stop unknown_order404 retry (deliberate) unexpected/transient failure 500 retry 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 /orderwith a valid quote and a permittedOrigincan create an order. Put your own authentication and authorization in front ofPOST /order(session check, signed request, API gateway — whatever fits your app). - A rate-limiter implementation. The SDK ships the
FiatRateLimiterinterface 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
sealedstrategy is stateless; thestorestrategy is interface-only. The SDK ships no server-side quote-store implementation — bring your ownFiatQuoteStateStoreadapter 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
createFiatinto 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.