PCI scope for a self-hosted checkout: what you are and are not on the hook for
Where the card number actually goes with Stripe and PayPal, what Autonnel stores instead, and which parts of PCI scope stay yours when you self-host.
Every conversation about self-hosting a checkout eventually arrives at the same half-asked question. Someone says “but then the card data is on my server, right?” and the answer is no, and the more useful answer is: here is the mechanism, and here is the part that genuinely is yours.
Self-hosting a checkout: what you are actually signing up to run has a five-sentence version of this. This is the long one, because the short version keeps getting read as reassurance when it is meant as a description.
The limits of what I can tell you
I am not a QSA. Nothing here is a compliance opinion, and nothing here tells you which Self-Assessment Questionnaire applies to your business. Autonnel is not “PCI compliant” and no software can be compliant on your behalf, because compliance is a property of your whole cardholder data environment, your integration choices and your transaction volume, not of a dependency in your package.json.
Confirm your own SAQ type and validation route with your acquirer or a QSA. That is not a disclaimer bolted on at the end; it is the actual conclusion, and everything below exists so you can have that conversation with accurate facts instead of a guess.
What I can describe precisely is the mechanism: where the card number travels, what ends up in your Postgres, and what does not.
Where the card number actually goes
Stripe
On checkout and upsell pages, Autonnel loads https://js.stripe.com/v3/ and the card form calls elements.create('card'), mounting the result into a <div> on your page. That element is an iframe served from Stripe’s origin. Your page owns the box; Stripe owns what is inside it. Your JavaScript cannot read those fields, and neither can a script you pasted into the page, because the same-origin policy is doing the work.
When the buyer submits, the form calls stripe.createPaymentMethod({ type: 'card', card: cardElement, billing_details }). The card number goes from the browser straight to Stripe. What comes back is paymentMethod.id, a pm_... reference.
That reference is what gets POSTed to your server:
{ "action": "confirm", "orderId": "...", "trackingId": "...", "paymentMethodId": "pm_..." }
That request is the first moment your infrastructure hears about the payment at all, and it carries a token, not a card. If Stripe asks for 3DS, your server responds with requiresAction and a client secret, the browser runs stripe.handleCardAction() inside Stripe’s frame, and your server learns the outcome rather than the challenge. Redirect-based 3DS flows are not supported and will decline, which is a real limitation and is documented in Stripe setup.
PayPal
PayPal is structurally simpler to reason about. The SDK is loaded from PayPal, and the express button opens PayPal’s own approval flow where the buyer authenticates with PayPal. Your server never sees a credential of any kind. It gets order and capture identifiers back. Details in PayPal setup.
What Autonnel stores instead
I went through prisma/schema.prisma for this post rather than trusting my memory of it, and I would encourage you to do the same. It is a public repository and the relevant model is about twenty-five lines long.
There is no column anywhere in the schema for a card number, a CVV or an expiry date.
payment_intents holds: provider, amountMinor, currencyCode, captureMethod, status, providerIntentId, and after capture providerChargeId, capturedMinor, feeMinor and capturedAt. Then three display fields: cardBrand, cardNetwork and last4. Those come back from the provider after the charge so a confirmation page can say “Visa ending 4242”. A brand and four digits cannot be used to make a payment. I am telling you what the field contains, not how your assessor scopes it.
transactions holds one row per charge and per refund: type (CHARGE or REFUND), status, amountMinor, currencyCode, provider, providerRefundRef, chargeRef, idempotencyKey and reason. Every field there is an amount, a status or a reference to something the provider holds. How those rows drive refund arithmetic is covered in the order state machine.
One honest edge to this argument: checkoutSnapshot on the intent stores the buyer’s full name, address, contact channel and the line items. That is personal data and it deserves encryption at rest, retention limits and access control. It is simply not card data. Two different obligations, and conflating them helps nobody.
The upsell, which is where people expect the catch
The post-purchase upsell charges the same card with one click and no second form. That sounds like you must have kept the card somewhere. You did not, and the two providers get there by different routes.
Stripe. At the initial checkout the intent is created against a Customer with setup_future_usage=off_session. Stripe vaults the payment method on Stripe’s side and returns two identifiers, which land in stripeCustomerId and stripePaymentMethodId on the intent row. Accepting an upsell means your server asks Stripe to charge that stored method off-session. The card lives in Stripe’s vault; you hold a pointer.
PayPal. For upsell flows the adapter creates an AUTHORIZE intent at checkout and defers capture until the buyer leaves the upsell chain, so accepted upsells are patched into the same PayPal order and captured once. Nothing extra is stored because nothing needs to be. The authorization is still open on PayPal’s side.
There is a security property buried in the Stripe version worth stating plainly, because it is more useful than the reassurance: stripePaymentMethodId is worthless to anyone who does not also hold your Stripe secret key. A database dump on its own charges nobody. A database dump plus your secret key is a genuine problem.
Which is why Autonnel stores provider credentials encrypted with CREDENTIALS_ENCRYPTION_KEY. Protecting that key is a more realistic priority than worrying about card-number theft from a table that has no card-number column.
What is genuinely yours when you self-host
None of the above removes work. It relocates it. Here is the relocated list.
TLS. From Domains: Autonnel does not issue TLS certificates, and you terminate HTTPS at your infrastructure layer. Cloudflare’s proxy or your platform’s certificate manager both do this for you, but you have to have done it.
Admin surface separation. ADMIN_DOMAIN decides which hostnames serve the admin UI, and every other hostname serves the storefront. The default failure mode is the safe direction: a misconfigured host shows a storefront, not a login. Set it deliberately anyway.
The two secrets. AUTH_SESSION_SECRET and CREDENTIALS_ENCRYPTION_KEY, generated once, stored where you store things you cannot regenerate. The compose file ships development defaults so first boot needs no configuration, which is convenient right up until it faces the internet. See installation.
Access control. Roles map to feature IDs, refunds sit behind their own feature, the Roles and Permissions tab is gated by PERMISSIONS_ADMIN_USER_IDS so an admin cannot promote themselves, and API keys are stored as a SHA-256 hash with an immutable writeAccess flag. All of that is configuration you own. See permissions and API keys.
Dependency and image updates. Pin an exact image tag in production and take upgrades on purpose.
What you inject into the checkout page. This is the one I would put first if the list were ordered by how often it goes wrong. Global scripts inject into every storefront page, including the HEAD of your checkout. A tag manager there runs in the same document as the div hosting Stripe’s iframe. It cannot read inside the iframe, but a compromised third-party script on a payment page is precisely the attack that has been getting attention. Keep that list as short as you can defend. See global scripts.
What the SAQ types are for, in general
Self-Assessment Questionnaires are validation instruments, and there are several because integration shapes differ. The lightest ones exist for merchants who have outsourced account-data functions to validated third parties; heavier ones exist for merchants whose own systems are more involved in the payment. Which one you may use is a function of your integration and your acquirer’s requirements.
One dated data point, because it is the change most relevant to anyone who controls the page their checkout renders on. On 30 January 2025 the PCI Security Standards Council announced a January 2025 revision of SAQ A that removed Requirements 6.4.3, 11.6.1 and 12.3.1 from the questionnaire and added an eligibility criterion requiring merchants to confirm their site is not susceptible to attacks from scripts that could affect their e-commerce systems. It took effect on 31 March 2025, retiring the October 2024 version. I cite that to show where the attention is, not to suggest that SAQ A applies to you.
The direction is the takeaway. The iframe boundary is well understood by now. Scrutiny has moved to the page hosting the iframe, and when you self-host, that page is unambiguously yours.
Hosted versus self-hosted moves the line, it does not delete it
A hosted platform runs the TLS, applies the patches, separates the admin surface and keeps the dependency tree current. That is real work you stop doing, and I would never pretend otherwise.
What it does not change is the card path. Browser to provider, both ways, same tokens, same iframe. Choosing hosted does not shrink your cardholder data environment relative to self-hosting the same integration, because the sensitive path was never running through either server. It changes who operates the machines around it.
In both cases the merchant account, the acquirer relationship and the validation paperwork stay yours. Nobody validates on your behalf. If you want the money path to be code you can read but not servers you have to run, Cloud is the same application with the operations handled, and the incumbent comparison lives on the open-source ClickFunnels alternative page.
The one-paragraph version
Card numbers travel from the buyer’s browser to Stripe or PayPal and never reach your server, which receives tokens. Postgres stores an order, a charge reference, transaction rows and a brand plus last four digits for the receipt, and has no column for a card number, a CVV or an expiry date. The upsell charges a vaulted payment method by reference, and that reference is inert without your provider secret key. What is yours is the perimeter: TLS, the admin domain, the two secrets, whatever you paste into the checkout page, and who can log in.
Take that description to your acquirer, and let them tell you which questionnaire to fill in.