Skip to content

Security ​

This page tells you what the OpenRampKit server protects, what it does not protect, and what you must do in your app. To report a vulnerability, see SECURITY.md.

Trust model ​

PartTrust
Your backendTrusted. It creates sessions, sets the destination, and credits users from signed webhooks.
OpenRampKit serverTrusted. It holds the provider keys and runs in your infrastructure.
Browser (client, web component, React)Not trusted. The user can change any request.
Provider pages and webhooksTrusted only after the adapter verifies the signature.
Chain RPCsTrusted for same-chain checks. Use your own RPC URLs in production.

The browser gets one client secret per session. The secret acts only on its own session. It cannot read or change another session.

What the server does ​

Sessions and secrets ​

  • Each session has a random secret (24 bytes). The server keeps only its SHA-256 hash, and compares hashes in constant time.
  • The session id in the URL must match the id in the secret. Else the answer is 401.
  • After expiresAt, a session cannot plan, quote, select or restart. A payment that started before the deadline can still finish.
  • secret must have at least 32 characters. webhooks.secret and tasksToken must have at least 16. The server does not start with a shorter value.

Input checks ​

  • CreateSessionInput: the server checks userId, metadata, ttlMinutes, country, amountBounds and the destination. See CreateSessionInput.
  • Browser routes: the server checks amounts (decimal strings), wallet addresses, chains (CAIP-2), tokens, withdraw targets and Idempotency-Key.
  • Body size: at most 64 KiB for a JSON body and 1 MiB for a provider webhook. A larger body gets 413.
  • Quote ids are looked up as own keys only. A value such as __proto__ finds nothing.
  • The quotes route sends a PublicQuote to the browser. It does not send the adapter data of each leg (provider URLs, request bodies, idempotency nonces). That data stays in the server store.

Idempotency and rate limits ​

  • An Idempotency-Key is scoped to the session and the route. A key cannot replay the answer of another session or another route.
  • Each session can make at most limits.providerCallsPerMinute (default 60) requests per minute to the routes that call providers.

Surface URLs ​

Provider URLs reach the browser as popups, iframes and links. The server checks them before the client sees them:

  • REDIRECT, IFRAME and a PROVIDER_SDK redirectUrl must use https:. In test mode, http: is also accepted.
  • DEEPLINK can use an app scheme, for example gcash://.
  • A javascript:, data:, vbscript:, blob:, file: or about: URL fails the leg with PROVIDER_UNAVAILABLE.

The client and the web component do the same checks again. They never open or embed a javascript: or data: URL.

A REDIRECT goes through a start URL on your server: {baseUrl}/start/{session}.{token}.{signature}. The signature is an HMAC with secret. The link expires after 10 minutes. The redirect has cache-control: no-store and referrer-policy: no-referrer. When the surface sets keepReferrer: true (Transak), it uses referrer-policy: strict-origin.

Adapter data ​

A third-party adapter can send anything. Every adapter step (start, transition, status) and every provider event goes through one entry check, sanitizeLegStep, before the server keeps it. This applies to the active payment and to earlier attempts:

  • Surface URLs: the checks of Surface URLs. An unsafe URL fails the step.
  • detail.code must be in STEP_DETAIL_CODES. detail.providerStatus must have at most 64 characters of letters, digits, spaces and _ - . :. The server drops other values.
  • action stays only with requires_action. phase stays only with pending and processing.
  • Transactions: a known role (not hop), a hash of letters and digits (an 0x prefix is allowed, at most 200 characters), and a CAIP-2 chain. The server drops other transactions. It keeps only role, chain, hash and amount: a link or another field from the adapter is dropped.
  • Explorer links: the server builds Transaction.explorerUrl from its own chain table (explorerTxUrl). It never takes a link from an adapter.
  • An adapter step with fields of the adapter API version 1 (state, sub, surface, transitions, txHash, sourceTxHash) gets a warning in the log. The server ignores these fields.

The server merges a later step into the current one, so it only keeps checked values.

Provider webhooks in ​

Each adapter verifies its provider's webhook with the raw body, before the server parses it:

AdapterCheckReplay window
CoinbaseHMAC-SHA256 of {t}.{body} (X-Hook0-Signature)5 minutes
MeldHMAC-SHA256 of {timestamp}.{url}.{body}5 minutes
MoonPayHMAC-SHA256 of {t}.{body} (Moonpay-Signature-V2)5 minutes
StripeHMAC-SHA256 of {t}.{body} (Stripe-Signature)5 minutes
PeerHMAC-SHA256 of {timestamp}.{body}5 minutes
OnramperHMAC-SHA256 of the bodynone (the provider signs no time)
SwappedHMAC-SHA256 of the bodynone (the provider signs no time)
TransakHS256 JWT signed with the access tokenJWT exp
XenditStatic callback tokennone (the provider signs nothing)

The adapters with a timestamped HMAC (Coinbase, Meld, MoonPay, Stripe, Peer) use the shared helper verifyTimestampedHmac from @openrampkit/adapter. An adapter without its webhook key refuses every webhook. All comparisons are constant time. A replayed event cannot change a leg that is already final.

Webhooks out ​

The server signs each webhook with HMAC-SHA256 over {id}.{timestamp}.{body} (Standard Webhooks: the headers webhook-id, webhook-timestamp and webhook-signature). verifyWebhook refuses a timestamp more than 5 minutes old. Webhooks are at least once: credit one time per session, and deduplicate by event id.

Withdrawals ​

  • The target address must have a valid format. The zero EVM address is refused.
  • allowedDestinations limits the chains, currencies and Lightning domains.
  • screenAddress fails closed: false, any other value, or an error refuses the address.
  • A Lightning target (a $cashtag or a Lightning address) is parsed on the server. screenLightningAddress screens it and fails closed. With screenAddress set and no Lightning hook, every Lightning target is refused. The payout adapter reads the recipient only from the stored target.
  • With custody: 'app', the server saves the session (with the version check) before it calls treasury.send. Two requests at the same time cannot both send. The idempotencyKey lets your hook drop a retry. The saved session shows the leg as processing, so a failure after the send cannot start a second payment from the treasury.

Same-chain payments (Relay) ​

A same-chain wallet payment completes only when the transaction succeeded, was mined after the payment started, paid the recipient the quoted amount, and did not complete another payment before. See Same-chain moves.

Errors and logs ​

  • The browser gets only OpenRampError codes and safe messages. A raw provider error, a stack trace or a key never goes to the browser.
  • The server does not log secrets, API keys or request headers.

CORS ​

Without cors, the server sends no CORS headers. With a list of origins, the server copies the Origin header only when it is in the list. The server never sends access-control-allow-credentials: the client secret goes in the Authorization header, not in a cookie.

Operational routes ​

POST /tasks/sweep and GET /health?deep=1 need Authorization: Bearer {tasksToken}. Without tasksToken, POST /tasks/sweep answers 404 and GET /health?deep=1 answers 401. The plain GET /health is public: it lists the adapter ids and calls no provider.

What you must do ​

  1. Credit users only from a verified webhook, or from openramp.sessions.retrieve() on your server. Never trust the browser.
  2. Credit each session or event one time only. Store the session id (or the destination hash of result.transactions) with a unique constraint. Credit the full amount only when result.delivery.status is ok.
  3. Keep secret, webhooks.secret, tasksToken and the provider keys in a secret store. Use 32 random bytes for each.
  4. Use a shared store with an atomic version check in production: durableObjectStore or redisStore.
  5. In authorize, decide userId and destination on the server. Do not copy them from the request body.
  6. With custody: 'app', check and debit the user's balance in treasury.send, one time per idempotencyKey. Check the amount and the recipient of txs. The server does not know the balance.
  7. Set screenAddress for withdrawals, and set amountBounds.currency to the source token symbol, so that the bounds apply.
  8. Do not configure the mock adapter in production. It refuses live sessions.
  9. Set your own rpcUrls for Relay in production.
  10. Put your own rate limits in front of POST /sessions and the webhook routes (for example at your CDN).

Known limits ​

  • x-forwarded-for and x-real-ip can be forged when no proxy sets them. The server gives the IP to providers as a hint only.
  • cloudflareKvStore has no atomic version check. Two requests at the same time can both write. memoryStore is atomic, but only inside one process.
  • A same-chain transfer to a destination address counts every transfer after the start block, from any sender. See Same-chain moves.
  • Onramper, Swapped and Xendit webhooks have no replay window, because the providers do not sign a time.

MIT licensed