Reduce PCI DSS Scope in Web Payments with Hosted Fields and Redirects
For developers designing or refactoring card payment flows, this guide explains how hosted payment pages and hosted fields reduce PCI DSS scope, what remains in scope anyway, and where teams accidentally pull themselves back into higher scope. You’ll get concrete architecture guidance, implementation patterns, and the trade-offs that matter when choosing between redirect, iframe fields, and direct-post designs.
TL;DR — If your web app ever receives, logs, stores, or can modify raw cardholder data handling in the browser-to-processor path, your PCI DSS scope expands fast. The most reliable scope reduction pattern is a full redirect or processor-hosted payment page; hosted fields can also reduce scope, but your site, JavaScript supply chain, and integration boundaries still matter and can keep parts of the environment in scope. Reading time: ~7 min
What it is and where it sits
For web payments, “PCI scope reduction” usually means changing the payment architecture so your application does not handle Primary Account Number (PAN), expiry, or CVV at all. In practice, the two common patterns are:
- Redirect / hosted payment page: your app sends the customer to a payment provider page to enter card data.
- Hosted fields: your checkout page stays on your domain, but the actual card input elements are served from the payment provider, usually in iframes, and tokenization happens directly between browser and provider.
What these patterns replace is the old direct-post or API pattern where your frontend collects card data into your own DOM and sends it to your backend or directly to a gateway with your code in the middle. That older pattern is exactly what tends to drag the app tier, logs, APM, WAF captures, support tools, and developer laptops into scope.
Architecture-wise, these patterns sit at the browser/payment boundary. They do not remove PCI concerns from everything around them. Your checkout app, order service, web server, CDN, CSP policy, session handling, and any page that can influence the payment flow still matter.
View as text
[Customer Browser] ---- 1. GET /checkout ----> [Merchant Web App] [Customer Browser] <---- 2. HTML + JS shell ---- [Merchant Web App] [Customer Browser] ---- 3. card data ----> [Payment Provider] [Customer Browser] <---- 4. token/result ---- [Payment Provider] [Customer Browser] ---- 5. token + order ----> [Merchant Web App] [Merchant Web App] ---- 6. capture/auth ----> [Payment Provider API]
In a redirect flow, step 3 happens on a provider-hosted page, not your checkout DOM. In hosted fields, step 2 includes provider JS or iframe containers, and step 3 happens inside those provider-owned fields.
The compliance angle: scope is about systems that store, process, or transmit cardholder data, and also systems that can impact the security of that data. That second clause is where developers get surprised. If your checkout page can inject JavaScript into hosted fields pages, alter form targets, or exfiltrate tokens/session state, your merchant environment still has real obligations.
How it actually works
Walk one realistic example: a React checkout using hosted fields plus server-side payment intent creation.
- The browser loads
/checkoutfrom your app. - Your backend creates a short-lived payment session or intent with the processor API and returns a client token or session ID. This call is server-to-server and contains amount, currency, order ID, and return URLs — not PAN.
- The page loads the provider’s JS from the provider domain and mounts hosted card number, expiry, and CVV fields into iframes.
- The customer types card data. Keystrokes go into provider-controlled iframes, not your DOM inputs.
- The provider JS tokenizes the card data directly with the provider and returns a payment token / method ID / nonce to your page.
- Your frontend submits only that token plus your order reference to your backend.
- Your backend calls the provider API to authorize or capture using the token.
- Your app stores order state and provider transaction IDs, but not raw PAN or CVV.
The important boundary is step 4–5. If implemented correctly, your network logs should never show PAN hitting your origin.
A concrete diagnostic check:
- Open browser devtools on checkout.
- Filter Network by your domain.
- Submit a test card.
- You should see token or session identifiers, but not a POST body containing
card[number],pan,cvv, or similar to your origin.
If you want to test from the edge, inspect request logs or reverse proxy captures carefully. For nginx, a bad sign is any access log format that includes full query strings or request bodies for payment endpoints.
Example of a redirect flow gone wrong: the app uses a 302 to a payment page, but leaves a fallback local card form enabled when the provider session creation fails. That fallback instantly changes your scope story.
Typical curl -I shape for a correct redirect initiation endpoint:
curl -I https://shop.example.com/pay/checkout/ord_123
HTTP/2 303
server: nginx
location: https://payments.example-processor.com/session/sess_9f2...
cache-control: no-store
content-length: 0
Typical broken shape when the app silently serves a local form instead of redirecting:
HTTP/2 200
server: nginx
content-type: text/html; charset=utf-8
cache-control: public, max-age=300
That 200 is not inherently non-compliant, but if the returned HTML contains your own <input name="card_number">, you are no longer in a reduced-scope hosted-page pattern.
When to use it (and when not to)
Use scope reduction patterns when your business goal is to accept card payments without owning card-data handling controls in your app stack.
| Scenario | Recommendation |
|---|---|
| Small team, standard checkout, low appetite for PCI operational burden | Full redirect / hosted payment page |
| Need branded checkout but can accept iframe-based card fields | Hosted fields |
| Need full control over payment UX, custom fraud flows, or multi-acquirer routing in-browser | Expect more scope; hosted fields may not be enough |
| You must support call-center keyed entry inside your own admin app | Separate environment; scope likely expands |
| You already have raw PAN touching your backend, logs, or analytics | Stop and redesign before optimizing anything else |
| You only sell through wallets where card entry never touches your site | You probably don’t need hosted fields for cards on web checkout |
You probably don’t need hosted fields if:
- a full redirect is acceptable for conversion and branding,
- your team is weak on frontend security controls like CSP and script governance,
- you cannot reliably inventory third-party scripts on checkout pages,
- you do not want to defend iframe integration edge cases across browsers and content blockers.
Use redirects over hosted fields when compliance simplicity beats UX polish. Hosted fields are often a good middle ground, but they are not “PCI-free checkout.”
Trade-offs
Every scope reduction benefit costs something.
-
Benefit: less card data exposure in your app
Cost: more dependence on provider SDK behavior, availability, and token semantics. -
Benefit: simpler evidence collection than direct card handling
Cost: you still need to harden the payment page, especially script loading, session security, admin access, and change control. -
Benefit: redirect flow is operationally clean
Cost: weaker UX continuity, harder branding consistency, and sometimes lower conversion. -
Benefit: hosted fields preserve on-site checkout UX
Cost: more frontend complexity: iframe styling constraints, async readiness states, CSP tuning, and browser quirks. -
Benefit: reduced blast radius if your backend is compromised
Cost: token misuse is still possible; attacker-controlled checkout JS can swap amounts, skim billing data, or abuse sessions even without PAN access. -
Benefit: less sensitive data in logs and databases
Cost: you must actively remove accidental captures from observability tools, error trackers, reverse proxies, and support screenshots.
Lock-in is real here. Redirect URLs, token objects, webhook event models, dispute workflows, and saved-payment-method semantics vary by provider. The more your order lifecycle depends on provider-specific states, the harder migration gets.
Latency is usually acceptable, but hosted fields and redirects add extra DNS/TLS connections to provider domains. On a bad mobile network, that can become visible. Budget for it in synthetic tests.
In practice
Example 1: nginx logging and headers for a checkout path
server {
listen 443 ssl http2;
server_name shop.example.com;
access_log /var/log/nginx/access.log main;
location /checkout {
add_header Cache-Control "no-store" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://js.payment-provider.example; frame-src https://js.payment-provider.example https://pay.payment-provider.example; connect-src 'self' https://api.payment-provider.example; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
proxy_pass http://app_upstream;
}
}
This disables caching on checkout and constrains where scripts, frames, and XHR/fetch can go. The gotcha: if your real checkout also loads analytics, tag managers, A/B testing, or fonts, this CSP will break them; that is often a feature, not a bug, for payment pages.
⚠️ Tightening CSP on a live checkout can break payment rendering and cause immediate revenue loss. Deploy first in report-only mode if your stack supports it, then switch to enforcing after reviewing violations.
A common validation command:
curl -I https://shop.example.com/checkout
Expected output shape:
HTTP/2 200
cache-control: no-store
content-security-policy: default-src 'self'; script-src 'self' https://js.payment-provider.example; frame-src https://js.payment-provider.example https://pay.payment-provider.example; connect-src 'self' https://api.payment-provider.example; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Example 2: Express endpoint creating a payment session, returning only a client token
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/payment-session", async (req, res) => {
const { orderId } = req.body;
const order = await loadOrder(orderId);
if (!order) {
return res.status(404).json({ error: "order_not_found" });
}
const providerRes = await fetch("https://api.payment-provider.example/v1/payment_sessions", {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": `Bearer ${process.env.PAYMENT_API_KEY}`
},
body: JSON.stringify({
amount: order.totalCents,
currency: order.currency,
reference: order.id,
return_url: `https://shop.example.com/payment/return?order=${order.id}`
})
});
if (!providerRes.ok) {
const text = await providerRes.text();
console.error("payment_session_create_failed", providerRes.status, text);
return res.status(502).json({ error: "payment_provider_unavailable" });
}
const session = await providerRes.json();
res.set("Cache-Control", "no-store");
return res.json({ clientToken: session.client_token });
});
This endpoint creates the provider-side payment session and returns only a token the browser can use to mount hosted fields or start a redirect. The gotcha: do not log full provider responses blindly; some providers include customer data, billing details, or redirect URLs with sensitive state.
A frontend anti-pattern to avoid is posting raw card fields to your own backend “just for validation.” If you need validation, use provider-side field validation events or browser-side formatting only.
Example 3: grep your codebase and logs for accidental card handling
grep -RInE "card(number|_number)|pan|cvv|cvc|expiry|exp_month|exp_year" ./src ./config ./templates
sudo zgrep -hE "card(number|_number)|pan|cvv|cvc|expiry" /var/log/nginx/* /var/log/app/* 2>/dev/null | head
This is a crude but useful first pass to find accidental form fields, debug statements, or log captures. The gotcha: absence of matches does not prove absence of scope; minified frontend bundles, APM payload capture, and third-party scripts can still create exposure.
Further reading
- PCI DSS v4.0.1
- PCI SSC SAQ Instructions and Guidelines
- OWASP Third-Party JavaScript Management Cheat Sheet
- MDN HTTP docs, the "Content Security Policy (CSP)" section
- NIST SP 800-63B
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI