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
| Part | Trust |
|---|---|
| Your backend | Trusted. It creates sessions, sets the destination, and credits users from signed webhooks. |
| OpenRampKit server | Trusted. 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 webhooks | Trusted only after the adapter verifies the signature. |
| Chain RPCs | Trusted 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. secretmust have at least 32 characters.webhooks.secretandtasksTokenmust have at least 16. The server does not start with a shorter value.
Input checks
CreateSessionInput: the server checksuserId,metadata,ttlMinutes,country,amountBoundsand 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
PublicQuoteto the browser. It does not send the adapterdataof each leg (provider URLs, request bodies, idempotency nonces). That data stays in the server store.
Idempotency and rate limits
- An
Idempotency-Keyis 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,IFRAMEand aPROVIDER_SDKredirectUrlmust usehttps:. In test mode,http:is also accepted.DEEPLINKcan use an app scheme, for examplegcash://.- A
javascript:,data:,vbscript:,blob:,file:orabout:URL fails the leg withPROVIDER_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.codemust be inSTEP_DETAIL_CODES.detail.providerStatusmust have at most 64 characters of letters, digits, spaces and_ - . :. The server drops other values.actionstays only withrequires_action.phasestays only withpendingandprocessing.- Transactions: a known role (not
hop), a hash of letters and digits (an0xprefix is allowed, at most 200 characters), and a CAIP-2 chain. The server drops other transactions. It keeps onlyrole,chain,hashandamount: a link or another field from the adapter is dropped. - Explorer links: the server builds
Transaction.explorerUrlfrom 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:
| Adapter | Check | Replay window |
|---|---|---|
| Coinbase | HMAC-SHA256 of {t}.{body} (X-Hook0-Signature) | 5 minutes |
| Meld | HMAC-SHA256 of {timestamp}.{url}.{body} | 5 minutes |
| MoonPay | HMAC-SHA256 of {t}.{body} (Moonpay-Signature-V2) | 5 minutes |
| Stripe | HMAC-SHA256 of {t}.{body} (Stripe-Signature) | 5 minutes |
| Peer | HMAC-SHA256 of {timestamp}.{body} | 5 minutes |
| Onramper | HMAC-SHA256 of the body | none (the provider signs no time) |
| Swapped | HMAC-SHA256 of the body | none (the provider signs no time) |
| Transak | HS256 JWT signed with the access token | JWT exp |
| Xendit | Static callback token | none (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.
allowedDestinationslimits the chains, currencies and Lightning domains.screenAddressfails closed:false, any other value, or an error refuses the address.- A Lightning target (a
$cashtagor a Lightning address) is parsed on the server.screenLightningAddressscreens it and fails closed. WithscreenAddressset 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 callstreasury.send. Two requests at the same time cannot both send. TheidempotencyKeylets your hook drop a retry. The saved session shows the leg asprocessing, 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
OpenRampErrorcodes 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
- Credit users only from a verified webhook, or from
openramp.sessions.retrieve()on your server. Never trust the browser. - Credit each session or event one time only. Store the session id (or the
destinationhash ofresult.transactions) with a unique constraint. Credit the full amount only whenresult.delivery.statusisok. - Keep
secret,webhooks.secret,tasksTokenand the provider keys in a secret store. Use 32 random bytes for each. - Use a shared store with an atomic version check in production:
durableObjectStoreorredisStore. - In
authorize, decideuserIdanddestinationon the server. Do not copy them from the request body. - With
custody: 'app', check and debit the user's balance intreasury.send, one time peridempotencyKey. Check the amount and the recipient oftxs. The server does not know the balance. - Set
screenAddressfor withdrawals, and setamountBounds.currencyto the source token symbol, so that the bounds apply. - Do not configure the mock adapter in production. It refuses live sessions.
- Set your own
rpcUrlsfor Relay in production. - Put your own rate limits in front of
POST /sessionsand the webhook routes (for example at your CDN).
Known limits
x-forwarded-forandx-real-ipcan be forged when no proxy sets them. The server gives the IP to providers as a hint only.cloudflareKvStorehas no atomic version check. Two requests at the same time can both write.memoryStoreis atomic, but only inside one process.- A same-chain
transferto 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.