WritingPayments

Hosted fields shrink PCI scope only until the first fallback path

Keep card numbers out of your servers by closing the fallback paths that reopen PCI scope: blocked iframes, agent order entry, cross-gateway retries and logs.

A customer in Leeds is paying 48.00 GBP for a pair of boots. The checkout page loads an iframe from the gateway, the customer types 4242 4242 4242 4242, 12/28 and 123 into fields that belong to the gateway's origin, and the gateway hands the page a reference such as tok_9f3a. Your server receives { token: "tok_9f3a", amount: 4800, currency: "GBP" } and charges it. No card number crossed your network, and that is what keeps most of the PCI DSS obligations off your systems.

That protection is conditional, and the condition is the one engineers forget: the scope of your assessment is set by the worst path a card number can take through your systems, not by the path you designed. Hosted fields shrink the scope only until someone opens a second route for the number, and the second route is nearly always a fallback added under pressure: the iframe that would not load, the phone order a support agent has to key in, the retry at another gateway. I would treat each of those as a scope decision, make it on purpose, and have the server refuse any number that arrives through a door nobody decided to open.

What the iframe moves and what stays with you

The hosted field moves two values: the primary account number and the security code. They travel from the customer's keyboard to the gateway's origin, and your server receives a token that works at that gateway and nowhere else. Your database holds the token, the last four digits, the expiry month and the scheme, which is enough to show "Visa ending 4242", to refund, and to charge again if the gateway stored the card.

flowchart TD
  C[Customer browser] --> F[Gateway iframe]
  F -->|card number and cvv| G[Gateway]
  G -->|token| F
  F -->|token| P[Your checkout page]
  P -->|token amount currency| S[Your API]:::accent
  S -->|charge the token| G
The hosted field path: the number goes to the gateway, your API only ever sees a token

What stays with you is the page the iframe sits in. A script on that page cannot read inside the frame, but it can swap the frame for a lookalike, or read a form you added yourself. That is why the current standard asks even a merchant who has outsourced the whole card entry to keep an inventory of the scripts on the payment page, to record why each one is there, and to detect when one changes. The small scope is small, not zero, and it stays small only while the page holds no field of yours that accepts a number.

The doors that reopen it

Each path below gets added to a checkout for a reasonable reason, and each one puts a PAN on a server you own.

DoorWhat carries the numberClose it with
Iframe blocked by an ad blocker or a content security policyA plain input on your page posting to your APIA redirect to the gateway's hosted page, with a metric on how often it fires
Support agent keying a phone orderAn admin form posting to your APIThe gateway's virtual terminal, with the agent's user id in the order reference
Retry at a second gateway after a declineA PAN kept to re-send, because the first gateway's token means nothing elsewhereThe second gateway's own hosted fields on the retry, or a tokenisation proxy
Customer pastes a card into chat or emailTranscripts and ticket attachmentsA pay-by-link the agent sends, and deletion of the message that carried the number
A request logger or error trackerThe request body written on every call or on a 500A guard that rejects the body before any logger runs

Each door is either closed or in scope, and the closed ones stay closed only if the server refuses a number when one is opened by accident.

A worked failure: the frame that would not load

On a Tuesday, a customer on a corporate laptop reaches the card step and sees a blank box: the company proxy blocks the gateway's domain, so the iframe never reports ready. Conversion for that segment falls, and a developer adds a fallback the same afternoon: if the frame has not signalled ready within 3 seconds, render three plain inputs and post card_number, expiry and cvv to POST /payments. The customer pays 48.00 GBP and sees a receipt. Nothing in the dashboards moves.

Your API has a logging interceptor that writes method, path and body for every request at info level, kept for 30 days. The body with 4242 4242 4242 4242 now sits in a log group that every engineer on the team can read. Two days later the gateway times out once, the handler throws, and the error tracker, which captures request bodies on an unhandled exception, attaches the card number to a ticket that is emailed to the on-call channel.

flowchart TD
  C[Customer browser] --> B{Iframe ready in 3 s}
  B -->|yes| F[Gateway iframe]
  B -->|no| I[Plain inputs on your page]:::accent
  F -->|token| S[Your API]
  I -->|card number| S
  S --> L[Request log]
  S --> E[Error tracker]
  S --> G[Gateway]
The fallback door: a blocked iframe sends the number through your API into three stores

Nobody learns about this from an alert. They learn when the acquirer's questionnaire asks whether any component stores, processes or transmits cardholder data, and the truthful answer has changed from no to yes for the API, the log store and the error tracker. Containment has three parts: a guard that rejects the request with 400 before the interceptor sees it, a fallback that redirects to the gateway's hosted page instead of rendering inputs, and a metric on blank frames so the business sees what the blocked segment costs.

The guard that refuses a card number

The guard is a tripwire, not a parser. It walks the request body, finds any run of 13 to 19 digits that passes the Luhn check, and refuses the request with the field path and never the value. I have integrated 15+ gateways in a PCI DSS-compliant environment, and the rule I apply to every new one is that the server refuses a card number rather than masking it. It must run before every logging interceptor and before the handler, and it must run on every route, including the admin ones.

TypeScript
// Simplified: a tripwire that refuses any request carrying a card number.
// Mount it before logging interceptors so a PAN is never written anywhere.
const DIGIT_RUN = /(?:\d[ -]?){13,19}/g;

function luhnValid(digits: string): boolean {
  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let d = Number(digits[i]);
    if (double) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    double = !double;
  }
  return sum % 10 === 0;
}

function findPan(value: unknown, path = '$'): string | null {
  if (typeof value === 'string') {
    for (const run of value.match(DIGIT_RUN) ?? []) {
      const digits = run.replace(/[ -]/g, '');
      if (digits.length >= 13 && luhnValid(digits)) return path;
    }
    return null;
  }
  if (value && typeof value === 'object') {
    for (const [key, child] of Object.entries(value)) {
      const hit = findPan(child, `${path}.${key}`);
      if (hit) return hit;
    }
  }
  return null;
}

export function panTripwire(req: Request, res: Response, next: NextFunction) {
  const hit = findPan(req.body);
  if (hit) {
    metrics.increment('pan_in_request', { route: req.route?.path ?? req.path });
    return res.status(400).json({ code: 'pan_in_request', field: hit });
  }
  next();
}

The pan_in_request counter is the real output. A single increment means a door opened: a new fallback, a new admin form, a customer pasting a number into a free-text field. The 400 stops the number landing; the counter tells you which route to go and close.

Card on file without a vault of your own

The retry door has the strongest pull, because a token from one gateway is useless at another, and the obvious fix is to keep the number so the routing engine can send it anywhere. Do not. For recurring and one-click payments, store the gateway's stored-card reference and let the gateway hold the card. For a retry at a second gateway, either show that gateway's own hosted fields on the retry screen, or put a tokenisation proxy in the browser path: the proxy's iframe collects the number, you hold the proxy's alias, and at authorisation time you ask the proxy to forward the alias to whichever gateway you routed to.

  1. Mount the tripwire

    Reject any body with a Luhn-valid run of 13 to 19 digits, before logging, on every route, and alert on the first hit.
  2. Replace the local fallback

    When the iframe does not report ready, redirect to the gateway's hosted page and count the redirect; never render inputs of your own.
  3. Move agent entry out

    Phone orders go through the gateway's virtual terminal or a pay-by-link; the admin form keeps the order id, not the card.
  4. Store references, not numbers

    Keep the gateway's stored-card reference for repeat charges, and use a proxy alias only when routing across gateways is a product requirement.
  5. Inventory the page scripts

    List every script on the payment page with a reason, and alert when the list or a script's hash changes.

The proxy is in scope for the number; you are not. It is also one more hop on every authorisation and one more party that can be down when the gateway is up, so I would add it only when cross-gateway routing is a product requirement.

Where this breaks

Some gateways, often regional ones, offer no hosted fields and no proxy integration, only a server-to-server call that takes the raw number. If the business needs that gateway, you are in scope for the number and should say so. The containment is to build one small service whose only job is to receive the PAN from the proxy and call that gateway, on its own network segment, with its own logs, and to assess that service alone while the rest of the platform stays outside.

The tripwire has false positives. Roughly one random digit string in ten passes Luhn, so a 16-digit shipment reference or a long order number in a free-text note will trip it. The fix is an allowlist of exact field paths, such as $.shipment.tracking_number, per route; the wrong fix is disabling the guard for the route, because the route that carries references is also the route a support agent will one day paste a card into.

The redirect fallback costs conversion: a full-page gateway form loses your layout and some customers abandon it. If the business will not accept that loss, the alternative is in scope, and the choice should be written down with its monthly cost beside it, made by the person who owns the questionnaire rather than by whoever fixed the blank box.

Build the tripwire first: it is forty lines, it runs on every route, and its first increment tells you where the next door is. Then replace the local fallback with the redirect, because that is the door that opens by itself.

Written by Md Nasim Anjum, senior full-stack engineer in Manchester. He builds payment orchestration, KYC and KYB compliance platforms and conversational AI.

Get in touchAll writingRSS

More writing