April 10, 2026 10 min read

CORS and Iframe Security for Embedded Payment Forms — The Clickjacking Attack That Almost Got Us

If you embed payment forms in iframes — and most of us do — you're one misconfigured header away from a clickjacking attack. I learned this the hard way during a security audit that found our checkout form could be framed by any domain on the internet. Here's everything I know about locking it down.

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:

Merchant Page your-store.com
iframe Embedded card form
Payment Provider js.stripe.com

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:

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:

  1. Set X-Frame-Options and frame-ancestors on every endpoint that serves payment UI. Use DENY if the page should never be framed. Specify exact origins if it should.
  2. Validate CORS origins server-side. Maintain an explicit allowlist. Never reflect the Origin header without checking it.
  3. Always specify targetOrigin in postMessage. Never use "*". On the receiving end, always check event.origin.
  4. Add sandbox attributes to iframes where possible. Use sandbox="allow-scripts allow-forms allow-same-origin" to limit what the framed content can do.
  5. Test headers in CI. Automated checks catch the ownership gaps that humans miss. Fail the build if security headers are absent.
  6. 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.
  7. Use SameSite=Strict or SameSite=Lax on session cookies. This adds another layer of defense against cross-site request forgery in framed contexts.
  8. Monitor with report-uri or report-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.

Disclaimer: This article reflects my personal experience and opinions, not those of any employer or payment provider. Security configurations vary by platform, compliance requirements, and threat model. Always consult your QSA (Qualified Security Assessor) and review the latest PCI DSS requirements before making changes to your payment infrastructure. Code examples are simplified for clarity and should be adapted to your specific environment.