Securing a headless checkout

When Flint hosts checkout, Flint owns the page's security. When you own the page, you own most of it. This guide covers what moves to you and what stays with Flint.

It assumes the integration in Build your own checkout.

The boundary#

Three parties, and the split is not negotiable.

HoldsWhat
Your backendYour Flint API key, the checkout auth token, customer session secrets, your own browser session
The browserYour session cookie, and short-lived Stripe values it received directly from Stripe.js
StripeCard and bank details, collected inside Stripe-hosted Elements frames

Card details never reach your servers or Flint's. That is the point of Elements, and it survives going headless.

What does not survive is the assumption that the browser can talk to the payment API. Flint does not accept requests from merchant origins. Production CORS permits Flint-owned origins only, and there is no publishable Flint key. Your storefront reaches Flint through your own backend, or not at all.

Do not send the checkout auth token, a customer session secret, or your API key to the browser. They are backend credentials. A checkout token is narrower than an API key, but it can still move money on its order.

Your backend is a relay, so treat it like one#

A backend-for-frontend that forwards whatever the browser asks for is an open proxy with your API key attached. Constrain it.

Derive Flint IDs from your own state. The browser should send a cart reference, not an ord_... or cs_.... Look up which order and session belong to the current signed-in session and use those. If the browser can name the resource, a buyer can name someone else's.

Expose jobs, not endpoints. Routes like /checkout/{cart}/select-delivery and /checkout/{cart}/pay are auditable. A generic /flint-proxy is not.

Pick the credential centrally. Every Flint call uses either checkout authentication or merchant authentication, never both; sending both returns 400 AMBIGUOUS_AUTH. Decide in one place rather than letting each handler assemble headers, so a route cannot quietly acquire more authority than it needs.

Apply your own limits. Flint rate limits its public API, but that is per credential, and your relay collapses every buyer onto one. Rate limit bootstrap, pricing, delivery, payment start, and payment resume by your own session and by network signals.

Protect your endpoints. Cookie-authenticated routes need CSRF protection and strict origin checks. These are ordinary web application concerns that Flint's hosted checkout was handling for you.

Credential hygiene#

  • Keys, checkout tokens, session secrets, client secrets, and ConfirmationTokens stay out of logs, analytics, URLs, error messages, and persistent browser storage.
  • A ctoken_... is single-use and bound to one payment leg. Never store it as retry authority and never resend it when resuming an attempt.
  • Scope backend keys to the job. Order checkout needs commerce.orders.read and commerce.orders.write; session management needs the checkout session scopes. Provision wallet domains with a separate administrative key rather than widening the checkout key.
  • Rotate on exposure. A leaked checkout token is bounded by its session and expiry; a leaked API key is not.

Your payment page runs your JavaScript#

This is the security change that matters most and gets the least attention.

Flint's hosted checkout page ran only Flint's code. Your page runs yours, plus whatever your tag manager, analytics vendor, session recorder, and A/B testing tool inject. All of that executes on the page that surrounds the payment fields.

The card data itself is still safe: it is inside Stripe's frames, and a script on your page cannot read across that boundary. What a malicious or compromised script can do is everything around it, including overlaying fake fields, changing displayed amounts, or redirecting the buyer.

So:

  • Serve checkout over HTTPS, and set a Content Security Policy that constrains which scripts may run.
  • Use subresource integrity where you load third-party code.
  • Keep an inventory of every script on the payment page and know why each one is there.
  • Be deliberate about session recorders and analytics on this page specifically.

PCI scope#

Using Elements means card data does not touch your systems, which is the largest single factor in reducing PCI scope. It does not by itself decide which self-assessment questionnaire applies to you.

PCI DSS 4.0.1 added two requirements that stay with the merchant whenever the payment page is yours:

  • 6.4.3: you inventory and authorize the scripts on your payment page and assure their integrity.
  • 11.6.1: you detect unauthorized modification of the payment page's headers and content.

Both are about the page, not the card, so moving collection into an iframe does not move them off you. This is the practical difference between a fully hosted page and a page you own that embeds hosted fields.

What we can tell you is the data flow: card details go from the buyer's browser into Stripe-hosted frames and on to Stripe; your servers and Flint's receive a token, never a PAN. What we cannot tell you is your SAQ eligibility, because that depends on your whole cardholder data environment, the other systems it touches, your acquirer, and your assessor. Take the data flow above to your QSA or acquirer and let them classify it.

Be wary of guidance that promises a questionnaire tier from the integration type alone. The tier depends on facts about your environment that no payments vendor can see.

Before you go live#

  • The API key and checkout credential are never sent to the browser.
  • Every relay route derives Flint IDs from server-side state.
  • One credential per Flint request, chosen centrally.
  • CSRF, origin checks, CSP, and rate limits are on your checkout routes.
  • Every script on the payment page is inventoried and justified.
  • Credentials appear in no log, URL, or browser storage.
  • Fulfillment keys off a signature-verified, deduplicated webhook or a backend read, never a browser success callback.
Rate this doc