Cardholder data is isolated to an edge CDE; the platform handles only PanTokens
Context
Inkress takes card payments on merchant storefronts. The naive design lets the cardholder type the PAN into the merchant's page and posts it to our servers — which drags the merchant's site, our web tier, our API and our database all into PCI DSS scope, and makes every one of them a place a card number can leak or be skimmed.
We wanted the strongest honest scope-reduction: a design where a raw PAN exists in exactly one small, auditable place and nowhere else, and where merchants can integrate with a script tag and stay at SAQ A rather than filling out a full self-assessment.
Decision
Confine every cleartext PAN to a single card data environment (CDE): a Cloudflare Worker (commerce-payments-edge) that is the only component that ever decrypts a card, and that retains nothing. Everything else on the platform handles an opaque PanToken (the provider's card token), never a PAN.
- Capture is on our origin, not the merchant's. The merchant embeds a thin published shim (
@inkress/card-elements, like@stripe/stripe-js) that injects an iframe served from our CDE origin; the cardholder types the PAN into our page. The merchant's DOM/JS never touches it, so the merchant stays SAQ A and the e-skimming controls (CSP, SRI, Page Shield) live on our domain where we can actually enforce them. Which sites may embed the iframe is an allowlist enforced via aframe-ancestorsresponse header. - PAN → PanToken conversion happens only in the CDE Worker. The iframe seals the card to the Worker (see ADR 0007 for the handshake); the Worker decrypts in-isolate, tokenizes at the provider, derives non-reversible matching data — a keyed HMAC-SHA256 fingerprint + BIN6 + last4 (never the PAN) — hands the PanToken to the control-plane vault, and returns only an opaque
card_ref. The decrypted PAN lives only in that request's memory. - The control plane is PAN-free, including the legacy path.
commerce-paymentsandcommerce-apiaccept PanTokens andcard_refs only. The PanToken is stored encrypted at rest in a vault (envelope AES-256-GCM) and decrypted only at charge time; CVV is never stored anywhere (PCI req 3.2). Even the FAC compatibility shim (ADR 0009) is PanToken-based, so no route ever carries a raw PAN.
Consequences
- Positive: the only PCI CDE is one ephemeral Worker plus the card-capture page it serves; the web tier, API, database and merchant sites are out of the PAN path. The defensible claim is "card data is handled only in an isolated, ephemeral function that retains nothing," not the stronger-but-false "card data never touches our servers."
- Positive: merchants integrate with a script tag and stay SAQ A; the sensitive code is one small, bounded, change-controlled repo.
- Neutral / to revisit: a Worker we deploy and hold the decryption key for is our CDE — this shrinks scope, it does not remove it. The card-capture page still carries PCI v4 e-skimming obligations (req 6.4.3 script inventory, req 11.6.1 tamper review) that are ours to own and cannot be one-click delegated.
- Neutral / to revisit: matching/dedup relies on the keyed fingerprint; the fingerprint key is versioned so it can rotate, but rotating it without a version tag would silently break dedup.
- Current state: the incumbent production card flow is the provider's own hosted page (PowerTranz/FAC), which is itself PAN-free for us (the cardholder types into the provider's page). The embedded edge CDE described here is the adopted direction, rolling out per path (ADR 0009); both models keep our control plane, API and merchants out of the PAN path — the CDE additionally moves the branded card capture onto our own origin.
Alternatives considered
- Cardholder types into the merchant's own page, posted to our API: rejected — it pulls the merchant site, our web tier, API and DB all into PCI scope and multiplies the places a PAN can leak; merchants would no longer qualify for SAQ A.
- Fully-hosted / redirect-only checkout (PAN goes browser → provider, we only ever see tokens): the smallest possible scope and a valid "card data never touches our servers" claim, but it surrenders control of the checkout UX. This is the incumbent model (the PowerTranz/FAC hosted page) and remains a valid fallback; the embedded CDE is the chosen direction precisely to reclaim a branded, on-our-origin checkout without giving the PAN to the merchant page or our core.
- Storing the PAN ourselves (encrypted) instead of a provider token: rejected — it keeps a reversible PAN in our datastore, the largest possible liability, for no functional gain over a provider PanToken.