Why Payment Forms Live in Iframes
If you've ever integrated Stripe Elements, Adyen Drop-in, or Braintree's hosted fields, you've used iframes whether you realized it or not. The reason is simple: PCI DSS scope reduction. When the card number input lives inside an iframe served from the payment provider's domain, the cardholder data never touches your servers. Your merchant page stays out of PCI scope entirely — the provider handles SAQ A compliance, and you sleep better at night.
The architecture looks like this:
The merchant page loads a JavaScript SDK. That SDK injects an iframe pointing to the provider's domain. The card form renders inside the iframe. When the user submits, the iframe communicates the tokenized card data back to the parent page via postMessage. Your server never sees a raw card number.
This is elegant. It's also a security surface area that most teams don't think about until something goes wrong.
The Clickjacking Threat
Here's the attack: an adversary creates a malicious page and embeds your checkout page in a transparent iframe. They position it so the "Pay Now" button overlaps with something the user actually wants to click — a "Claim Your Prize" button, a video play icon, whatever. The user thinks they're clicking one thing, but they're actually interacting with your payment form underneath.
In a more sophisticated variant, the attacker pre-fills form fields using URL parameters or manipulates the iframe's CSS to expose only the submit button. The user has no idea they're authorizing a payment. This is clickjacking, and it's been in the OWASP Top 10 adjacent risks for years.
The fix is straightforward in theory: tell browsers which domains are allowed to frame your page. In practice, there are two competing mechanisms, and you probably need both.
X-Frame-Options vs CSP frame-ancestors
These two HTTP response headers solve the same problem — controlling who can embed your page in an iframe — but they come from different eras of web security.
| Feature | X-Frame-Options | CSP frame-ancestors |
|---|---|---|
| Introduced | 2009 (IE8) | CSP Level 2 (2014) |
| Multiple origins | No | Yes |
| Wildcard subdomains | No | Yes (*.example.com) |
| Scheme support | No | Yes (https:) |
| Legacy browser support | Excellent | IE 11 ignores it |
| Spec status | Deprecated (but still honored) | Active standard |
My recommendation: set both. Use frame-ancestors as your primary control and X-Frame-Options as a fallback for older browsers. Here's what that looks like in nginx:
# nginx.conf — payment form endpoint
location /checkout/embed {
add_header X-Frame-Options "ALLOW-FROM https://your-store.com" always;
add_header Content-Security-Policy "frame-ancestors https://your-store.com https://*.your-store.com" always;
}
And if you're serving the payment form from a Go service:
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy",
"frame-ancestors https://your-store.com https://*.your-store.com")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
Tip: If your payment form should never be framed (e.g., a standalone checkout page), use X-Frame-Options: DENY and frame-ancestors 'none'. Don't leave it open just because you haven't decided yet.
CORS Configuration for Payment APIs
CORS and framing are different problems, but they show up together in payment integrations. Your merchant page's JavaScript needs to call the payment provider's API to create tokens, confirm intents, or fetch session data. That's a cross-origin request, and the browser will block it unless the provider's server sends the right CORS headers.
Here's what a well-configured CORS setup looks like for a payment API:
Access-Control-Allow-Origin: https://your-store.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Idempotency-Key
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
A few things I've learned the hard way:
- Never use
Access-Control-Allow-Origin: *with credentials. Browsers will reject it outright. If you need cookies or auth headers, you must specify the exact origin. - Whitelist origins explicitly. Maintain a list of allowed merchant domains and validate the
Originheader against it on every request. Don't reflect the origin blindly — that's equivalent to*with extra steps. - Cache preflight responses. Payment forms fire a lot of OPTIONS requests. Set
Access-Control-Max-Ageto at least 7200 seconds (2 hours) to reduce latency on repeat visits. - Include
X-Idempotency-Keyin allowed headers. Payment APIs rely on idempotency keys to prevent duplicate charges. If you forget this header in your CORS config, retries will fail silently.
postMessage Security — The Part Everyone Gets Wrong
The iframe and the parent page communicate via window.postMessage. The payment SDK uses it to send tokenized card data, validation errors, and resize events back to your merchant page. This is where I see the most mistakes.
Warning: Never use "*" as the targetOrigin in postMessage. This sends the message to any window that has a reference to yours — including an attacker's page that framed you. Always specify the exact origin: iframe.contentWindow.postMessage(data, "https://js.stripe.com")
On the receiving side, always validate the origin before processing the message:
// Merchant page — listening for payment token
window.addEventListener("message", (event) => {
// Reject messages from unknown origins
const allowedOrigins = [
"https://js.stripe.com",
"https://checkoutshopper-live.adyen.com"
];
if (!allowedOrigins.includes(event.origin)) {
console.warn(`Rejected postMessage from ${event.origin}`);
return;
}
// Validate message structure before processing
if (event.data && event.data.type === "payment_token") {
handlePaymentToken(event.data.token);
}
});
I've reviewed codebases where the message event listener had zero origin checks. Any page on the internet could have sent a crafted message and the handler would have processed it — potentially triggering a payment confirmation with attacker-controlled data.
The Audit That Changed Everything
Last year, we hired a third-party firm to pen-test our checkout flow. We were confident. We had TLS everywhere, tokenized card data, PCI SAQ A compliance. The works.
The auditor's report came back with a critical finding: our payment form endpoint returned no X-Frame-Options header and no frame-ancestors directive. The form could be embedded in an iframe on any domain. The auditor built a proof-of-concept page that overlaid our real checkout form with a fake "customer survey" UI. A user clicking through the survey was actually tabbing through our card fields and hitting submit.
The root cause was embarrassingly simple. We had the headers configured in our main nginx config, but the payment form was served by a separate Go microservice behind a different load balancer. That service had its own middleware stack, and nobody had added the framing headers. The infrastructure team assumed the CDN handled it. The CDN team assumed the application handled it. Classic ownership gap.
We fixed it in an hour. Added the headers to the Go middleware, deployed, verified with curl -I. But the real fix was adding a CI check that fails the build if any response from our payment endpoints is missing security headers. We use a simple integration test:
func TestPaymentEndpointSecurityHeaders(t *testing.T) {
resp, err := http.Get(paymentFormURL)
require.NoError(t, err)
defer resp.Body.Close()
xfo := resp.Header.Get("X-Frame-Options")
assert.Equal(t, "DENY", xfo, "X-Frame-Options missing or incorrect")
csp := resp.Header.Get("Content-Security-Policy")
assert.Contains(t, csp, "frame-ancestors", "CSP frame-ancestors missing")
}
Practical Checklist
Here's what I run through every time we ship a payment integration or change our checkout infrastructure:
- Set
X-Frame-Optionsandframe-ancestorson every endpoint that serves payment UI. UseDENYif the page should never be framed. Specify exact origins if it should. - Validate CORS origins server-side. Maintain an explicit allowlist. Never reflect the
Originheader without checking it. - Always specify
targetOrigininpostMessage. Never use"*". On the receiving end, always checkevent.origin. - Add
sandboxattributes to iframes where possible. Usesandbox="allow-scripts allow-forms allow-same-origin"to limit what the framed content can do. - Test headers in CI. Automated checks catch the ownership gaps that humans miss. Fail the build if security headers are absent.
- Audit your load balancer and CDN config. Headers set at the application layer can be stripped or overridden by reverse proxies. Verify the headers the browser actually receives, not just what your app sends.
- Use
SameSite=StrictorSameSite=Laxon session cookies. This adds another layer of defense against cross-site request forgery in framed contexts. - Monitor with
report-uriorreport-to. CSP reporting tells you when someone tries to frame your page from an unauthorized origin. Set it up and actually read the reports.
Remember: Security headers are only as strong as the weakest endpoint in your checkout flow. One unprotected microservice, one misconfigured CDN rule, and the entire chain breaks.