Security
Security
How to report a security problem, and what protects every page and every request. Each fact here is read from the file or check that enforces it, and the build fails when the two disagree.
Report a security problem
Email mathias@cryptopaymentfinder.com with what you found and what it lets someone do; how to reproduce it, with a proof of concept if you have one; and the addresses, endpoints or receipt ids it affects.
The same address, and a link to this page, are in /.well-known/security.txt, the file security tools read (RFC 9116).
In scope
- chainchoice.io, its subdomains and every address under them, including the API under /api/ and the public API under /api/v1/.
- chainchoice.vercel.app, which serves the same deployment.
Out of scope
- Services we use but do not run, such as our hosting, database and data providers: please report those to the vendor.
- Volumetric denial-of-service tests.
- Findings that need control of the reader's own device.
Disclosure
Please do not publish details until a fix has shipped or 90 days have passed since your report, whichever comes first.
Safe harbour
Good-faith research that respects user privacy, avoids data destruction, and doesn't degrade service for others will not be pursued legally. Do not access, modify, or exfiltrate data that isn't yours; use test accounts.
Response time
No response time is promised here: nothing yet records when a report arrived and when it was answered.
Acknowledgments
Nobody is listed yet.
If you want credit for a report, say so in it, and you will be listed here with the date and what you found.
The headers every page sends
vercel.json gives these headers to every address outside /embed/. A build check reads them for every published address and fails when one is missing or weaker (check-security-headers). A scheduled check compares what chainchoice.io serves with vercel.json, on every odd day of the month at 07:37 UTC.
Beside each one is the same header as https:/
X-Frame-Options
DENYSAMEORIGINX-Content-Type-Options
nosniffnosniffReferrer-Policy
strict-origin-when-cross-originno-referrer-when-downgradePermissions-Policy
accelerometer=(), attribution-reporting=(), autoplay=(), bluetooth=(), browsing-topics=(), camera=(), captured-surface-control=(), clipboard-read=(), compute-pressure=(), cross-origin-isolated=(), digital-credentials-get=(), display-capture=(), encrypted-media=(), fullscreen=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), identity-credentials-get=(), idle-detection=(), interest-cohort=(), join-ad-interest-group=(), keyboard-map=(), language-detector=(), local-fonts=(), local-network=(), local-network-access=(), loopback-network=(), magnetometer=(), microphone=(), midi=(), on-device-speech-recognition=(), otp-credentials=(), payment=(), picture-in-picture=(), private-aggregation=(), private-state-token-issuance=(), private-state-token-redemption=(), publickey-credentials-create=(), publickey-credentials-get=(), run-ad-auction=(), screen-wake-lock=(), serial=(), shared-storage=(), shared-storage-select-url=(), storage-access=(), summarizer=(), translator=(), usb=(), window-management=(), xr-spatial-tracking=(), ch-ua-high-entropy-values=(self), clipboard-write=(self)not sentStrict-Transport-Security
max-age=63072000; includeSubDomains; preloadmax-age=63072000; includeSubDomains; preloadContent-Security-Policy
default-src 'self'; script-src 'self' 'sha256-P+Og7RVPlxhL+xmcANStuG1tWgHIMYdDUu/CcgryZP8=' 'sha256-VevY99NeqUf/WU62FN0e8k1xmjhXdfoyRMtju/c02bc=' 'sha256-X1kOQ88SkhPMCWC0GFaqQ2iy5P9HbZQOOs3fz66rc1I=' https://static.cloudflareinsights.com https://plausible.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.anthropic.com https://*.supabase.co https://cloudflareinsights.com https://plausible.io https://api.coingecko.com https://api.llama.fi https://yields.llama.fi https://*.ingest.de.sentry.io; frame-ancestors 'none'base-uri 'none'; child-src 'none'; connect-src https://c.increment.com https://c.stripe.dev https://c.stripe.global https://c.stripe.partners blob: https://b.stripecdn.com https://errors.stripe.com https://ext.stripe.com https://r.stripe.com https://stripe-images.s3.us-west-1.amazonaws.com https://stripe.com 'self'; default-src 'none'; font-src https://b.stripecdn.com 'self'; form-action https://stripe.com 'self'; frame-ancestors https://app.contentful.com 'self'; frame-src https://b.stripecdn.com https://js.stripe.com https://support-conversations.stripe.com 'self'; img-src data: https://assets.ctfassets.net https://assets.stripeassets.com https://b.stripecdn.com https://images.ctfassets.net https://images.stripeassets.com https://q.stripe.com 'self'; manifest-src 'none'; media-src https://assets.ctfassets.net https://assets.stripeassets.com https://b.stripecdn.com https://videos.ctfassets.net https://videos.stripeassets.com 'self'; object-src 'none'; script-src https://b.stripecdn.com https://js.stripe.com 'self' 'sha256-3aWvb9tRBjmz1OjR3n7mwiTm94+s4iki4mMZF82asmc=' 'sha256-5LtzXhT7UFn+GqP5pKEMGL08UNZsrzANHFEBW/mQHGw=' 'sha256-beLzNcen8LrazzSCRjAapoIMTgJI0osPWGNSX7aK6lc=' 'sha256-cCM0Z4lzGkzQnmbdVw+ouz0JRawyaKcZ4yiqzqYS7ek=' 'sha256-vTifGUJH6hJYTvstw4xJ4xfr/vE0ELkOV4GpCumyqfg=' 'sha256-KxhSaxKB5RFTQsqfRwp+zG7iLjvMrTAySqnSvWlqct0=' 'sha256-tMuJ8c00j54yuxogrdIJeGhNVB350dc56i969XRz/Mc=' 'sha256-aEFSvCaVnb2wNwuO3IzA8J44RdTKt6vms9beA7BcCYg=' 'sha256-0SWEc2BfR2o77i2vUiNNIrFKQkjc2Ujsr2hlfZ6oUek=' 'report-sample'; style-src https://b.stripecdn.com 'self' 'unsafe-inline'; worker-src https://b.stripecdn.com 'self'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=dyUPtBcnp8tEbmd5ufldGw-tQczdrkfRWPcLfRkPMKX-UbmhbDZVLi2OzejoCwY%3D; report-to cspBrowser features
The Permissions-Policy denies 51 features to every origin, this site included, because no code the site ships calls them.
accelerometer, attribution-reporting, autoplay, bluetooth, browsing-topics, camera, captured-surface-control, clipboard-read, compute-pressure, cross-origin-isolated, digital-credentials-get, display-capture, encrypted-media, fullscreen, gamepad, geolocation, gyroscope, hid, identity-credentials-get, idle-detection, interest-cohort, join-ad-interest-group, keyboard-map, language-detector, local-fonts, local-network, local-network-access, loopback-network, magnetometer, microphone, midi, on-device-speech-recognition, otp-credentials, payment, picture-in-picture, private-aggregation, private-state-token-issuance, private-state-token-redemption, publickey-credentials-create, publickey-credentials-get, run-ad-auction, screen-wake-lock, serial, shared-storage, shared-storage-select-url, storage-access, summarizer, translator, usb, window-management, xr-spatial-trackingch-ua-high-entropy-values is allowed to this site and no other, for the high-entropy user-agent values (getHighEntropyValues): the script at https:/
clipboard-write is allowed to this site and no other, for writing to the clipboard: 5 files on this site use it, among them src/
web-share is left out, for the share sheet: 4 files on this site use it, and Chrome 147.0.7727.56 does not recognise it in this header, so it keeps the browser’s default.
The one framing exception
Pages under /embed/ send frame-ancestors * and no X-Frame-Options, because publishers place these widgets in an iframe on their own sites. They show the widget alone, without the site's navigation, footer or assistant, and their links open a new tab: a framed widget has no form or button to press on a reader's behalf. Every other page sends X-Frame-Options DENY and frame-ancestors 'none', so no other site can show it inside a frame.
Scheduled jobs refuse requests without their token
vercel.json schedules 30 jobs. Each runs only when the request carries the secret token Vercel sends, and refuses every request while that token is not configured. One of them, signals-refresh, also runs inside the public /api/signals read when the signals it serves are more than three minutes old: in the same function, so the token never leaves it. A request header such as x-vercel-cron opens nothing: anyone can send one.
Every handler checks the token through one function, which refuses while it is not configured and compares in constant time. A build check reads every handler that checks it, 50 today, and fails on one that names the token itself, on one that does not refuse without it, or on a scheduled path that reaches none of them (check-cron-auth-fails-closed).
On 13 October 2026 each of the 14 scheduled paths on chainchoice.io was requested without the token, then with x-vercel-cron: 1. Every request was refused with HTTP 401. 16 paths scheduled since then were not requested: /api/data/source-watch-cron, /api/data/liveness-probe-cron, /api/data/extraordinary-review-cron, /api/data/ops-monitor-cron, /api/data/asset-market-refresh, /api/data/register-sync-cron, /api/newsletter-cron, /api/winback-cron, /api/push-cron, /api/digest-cron, /api/data/retention-sweep-cron, /api/data/traffic-daily-cron, /api/data/index-coverage-cron, /api/data/bing-submit-cron, /api/data/feedback-cron and /api/search-model-eval-cron.
Keys in the code, and its packages
gitleaks scans the repository for keys and tokens in continuous integration, on day 1 of every month at 03:11 UTC and every day at 02:23 UTC, and on every pull request, with the full history checked out. A second workflow scans the whole history every Monday at 04:23 UTC.
npm audit fails on any critical or high advisory that has no written exception. An exception names its reason and lapses within 30 days. 8 are open, the first until 4 November 2026.
Dependabot proposes updates weekly for npm packages and weekly for GitHub Actions.
The build fails if either scan is made advisory or loses its full history, or if a workflow holds a literal key (check-security-ci), and it tests the audit’s own rules on every build (check-npm-audit).
Recovery phrases and keys never leave a field
Every input field, text area and rich-text field on this site removes a recovery phrase or a private key as it is typed or pasted, before anything can send it, and says why.
The English word list loads when a field takes focus. The other lists load when a text area or rich-text field takes focus, or when a field holds 8 words or a Solana key's shape, and the field is read again once they have. A paste of 12 words or more, or shaped like a Solana key, waits until they have loaded and the guard has read it. If they cannot load, it goes in after the English and key checks alone.
11 of the 12 API routers refuse a request that carries one with HTTP 400 before its handler runs (check-api-routers-guarded); api/og.tsx draws share images and does not.
On a fixed corpus of 41 secrets, a phrase in each of the 10 lists and each key format, the guard refuses 41. Of 34 look-alikes, such as 0x transaction hashes, addresses, Solana signatures and prose in each list's language, it refuses 0. Of the 7 digests and transaction ids it holds, it refuses 7, as the list below says.
What counts
- 12 or more consecutive words of the BIP-39 English list, in any case, with anything but letters between them: spaces, commas, line breaks or a numbered list.
- A recovery phrase in Czech, French, Italian, Japanese, Korean, Portuguese, Simplified Chinese, Spanish or Traditional Chinese: 12 or more words of one of those BIP-39 lists in a row that hold a phrase whose checksum is valid.
- Sixty-four hexadecimal characters standing alone.
- A WIF key: 51 or 52 base58 characters starting with 5, K or L.
- An extended private key starting xprv, yprv, zprv or tprv.
- A Solana private key: 64 bytes, as 86 to 90 base58 characters or as the array of 64 numbers a keypair file holds, whose second half is the public key of its first, as in every Solana keypair.
What passes
- 11 list words in a row: the shortest phrase a wallet issues has 12.
- A transaction hash written with 0x.
- An Ethereum, Bitcoin or Solana address.
- A Solana transaction signature: it has a key's 64 bytes, but its second half is not the public key of its first.
- Ordinary sentences: the English list has no "the", "and", "to", "of" or "in", so prose breaks a run within a few words, and a run in another list must carry a valid checksum.
Refused, though harmless
- A SHA-256 digest or a Bitcoin transaction id written as 64 hexadecimal characters, even with its label: a label is text anyone can put before a key.
/api/v1/verify takes signed receipts back. There, a value of exactly 64 lowercase hexadecimal characters under a key ending in Hash or Sha256 is read as the digest it is. Every other value in the request is still checked.
Not caught yet
- A phrase in one of the other lists with a word mistyped: its checksum no longer holds, and without one a run of list words reads as prose.
- A Solana key's 32-byte seed alone in base58, without its public half: it has the shape of a Solana address.
Logs
The API’s logger replaces an email address or IP address in a line with a short hash of it, and hashes any value under a key named as an email address, IP address, user id or query, before the line is written (api/_lib/log.ts).
A build check reads every console and log call in the API, and fails on one that passes such a value outside hashForLog, redactForLog or redact (check-logs-redact).
Software bill of materials
Every deploy publishes the packages it runs as a CycloneDX 1.5 document at /.well-known/sbom.cdx.json: every package a production install of package-lock.json holds, each with its integrity hash and download address, and its licence where the lockfile names one.
The build writes it from package-lock.json, records its SHA-256 in /data/build-checks.json under build.sbom.sha256, and fails when the file that ships is missing, stale or not that one (check-sbom). It carries no timestamp: rebuilt from the same commit, it hashes the same.
Built from vercel.json, data/security/policy.json, the probes recorded on 13 October 2026 and the checks named above. Each check’s latest result is on /status.