Back to Security
Security
easy
mid

How would you safely serialize user input before inserting it into the DOM?

For text content: use textContent / framework escaping ({value}) — no function needed. For attribute values: escape & < > ' ". For HTML rendering: DOMPurify with a strict allowlist (no script, on*, javascript: URIs). For URL attributes: validate scheme via URL constructor. For payment-specific (card numbers, CVV): strip non-digits, never log, mask in UI. Never roll your own HTML sanitizer with regex.

9 min read·~15 min to think through

For payment forms specifically (card number, name on card, billing address echoed back, error messages), the rules are extra strict because the threat is twofold: XSS in the form and PII leakage in logs/responses.

The hierarchy of safe rendering

1. Plain text (default — no function needed):

tsx
<div>{cardholderName}</div>             // React escapes — safe
<input value={cardholderName} />         // React escapes — safe
element.textContent = cardholderName;    // safe in vanilla

If you're just displaying text, use the framework's interpolation or textContent. No sanitizer needed.

2. Attribute values (escape minimum 5 chars):

ts
function escapeAttr(s: string): string {
  return s.replace(/[&<>"']/g, c => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  })[c]!);
}

React handles this for you on attribute interpolation. Use the explicit escape only if you're building HTML strings server-side.

3. URL attributes (href / src / action):

ts
function safeUrl(input: string, base = window.location.origin): string {
  try {
    const u = new URL(input, base);
    const allowed = new Set(['http:', 'https:', 'mailto:', 'tel:']);
    return allowed.has(u.protocol) ? u.toString() : '#';
  } catch {
    return '#';
  }
}

Blocks javascript:, data:, vbscript:, malformed URLs.

4. Rich HTML (only if you must accept HTML):

ts
import DOMPurify from 'dompurify';

const RICH_TEXT_CONFIG = {
  ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'ul', 'ol', 'li'],
  ALLOWED_ATTR: [],
  ALLOWED_URI_REGEXP: /^(?:https?:\/\/|mailto:)/,
};

export function sanitizeHTML(input: string): string {
  return DOMPurify.sanitize(input, RICH_TEXT_CONFIG);
}

For payment forms, you usually don't accept HTML at all — pure text fields. Drop this layer entirely if not needed.

Payment-form-specific

Card numbers, CVVs, expiry, billing names — these are all plain text inputs. The risks:

  • XSS via reflected error messages: "Card '<script>...</script>' was declined" → script runs.
  • PII in logs: full card number in server log → PCI compliance violation.
  • PII in URLs: GET request with card number in query string → leaks to referrer + CDN logs.
  • Inadvertent rendering: error response includes the full card; you display it; now it's in the DOM and the user's clipboard.

Strip / mask / validate

ts
function stripPAN(input: string): string {
  return input.replace(/\D+/g, '');
}

function maskPAN(pan: string): string {
  const digits = stripPAN(pan);
  if (digits.length < 4) return '****';
  return `****\u00a0****\u00a0****\u00a0${digits.slice(-4)}`;
}

function isLuhnValid(pan: string): boolean {
  const digits = stripPAN(pan);
  let sum = 0, alt = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let n = +digits[i];
    if (alt) { n *= 2; if (n > 9) n -= 9; }
    sum += n;
    alt = !alt;
  }
  return sum % 10 === 0 && digits.length >= 12;
}

Payment best practice: don't handle PAN at all

Use Stripe Elements, Adyen, Braintree Hosted Fields — they embed an iframe from the payment processor's domain so the PAN never touches your origin. You get a token (tok_xxx) instead. PCI scope shrinks from "all of your servers" to "the iframe URL."

If you do handle PAN: full PCI-DSS audit, network segmentation, key management, etc. Most apps shouldn't.

Reflected error messages

Server returns { error: "Card 'XYZ' was declined" }. You render:

tsx
// Safe — React escapes
<div>{error.message}</div>

But if anywhere you build HTML manually (innerHTML, dangerouslySetInnerHTML), the same XYZ becomes a script execution. Default to text rendering.

Logging

Mask sensitive fields before logging:

ts
function safeLog(payload: any): any {
  const SENSITIVE = ['card', 'cvv', 'pan', 'cardNumber', 'cvc', 'pin', 'password', 'token', 'ssn'];
  return JSON.parse(JSON.stringify(payload, (k, v) =>
    SENSITIVE.includes(k.toLowerCase()) ? '[REDACTED]' : v
  ));
}

Or use a logging library with redaction (Pino has built-in redact paths).

Putting it together: a payment-form-safe utility

ts
import DOMPurify from 'dompurify';

export const safe = {
  text(input: string): string {
    return input ?? '';   // framework interpolation will escape
  },
  url(input: string, allowed = ['http:', 'https:', 'mailto:']): string {
    try {
      const u = new URL(input);
      return allowed.includes(u.protocol) ? u.toString() : '#';
    } catch { return '#'; }
  },
  html(input: string): string {
    return DOMPurify.sanitize(input, {
      ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong'],
      ALLOWED_ATTR: [],
    });
  },
  pan: { strip: stripPAN, mask: maskPAN, isLuhnValid },
  log: safeLog,
};

Pitfalls

  • Building a regex HTML sanitizer instead of using DOMPurify — always bypassed.
  • Including PAN/CVV in any URL or log.
  • Rendering server error messages via dangerouslySetInnerHTML.
  • Storing card data in localStorage / form state longer than needed.
  • Handling PAN at all when a hosted-field solution would shrink PCI scope.
  • Trusting the server to never echo malicious content — it can if previously poisoned.

Mental model

Three layers: framework-escape text by default; sanitize HTML with DOMPurify only if you must accept HTML; validate URL schemes for href/src. For payment forms, additionally: prefer hosted fields, mask + Luhn validate, never log PAN/CVV. Never write a regex sanitizer.

Follow-up questions

  • Why is rolling your own HTML sanitizer a bad idea?
  • How do hosted payment fields shrink PCI scope?
  • What's the Luhn algorithm?
  • When is dangerouslySetInnerHTML acceptable?

Common mistakes

  • Building a regex HTML sanitizer.
  • Logging full PAN/CVV.
  • PAN in URL query string.
  • Rendering error messages via innerHTML.
  • Storing card data in localStorage.
  • Handling raw PAN when a hosted field would do.

Performance considerations

  • DOMPurify is fast (<1ms typical). Luhn check is trivial. The cost of getting it wrong (PCI fine, breach, brand damage) is dollars, not milliseconds.

Edge cases

  • Card with embedded spaces/dashes — strip before validation.
  • Apple Pay / Google Pay return tokens, not PANs — different validation path.
  • Some processors return masked PANs in receipts — display, don't log.
  • Auto-fill from password managers may inject unexpected formatting.
  • Pasting multi-line content into single-line fields — strip newlines.

Real-world examples

  • Stripe Elements, Braintree Hosted Fields, Adyen Components — all isolate card input via iframe.
  • PCI-DSS SAQ-A scope reduction is a major reason to use hosted fields.
  • OWASP Cheat Sheet on Input Validation is the canonical reference.

Senior engineer discussion

Seniors prefer hosted payment fields to shrink PCI scope, default to framework escaping for text, sanitize HTML only when unavoidable (DOMPurify), validate URL schemes, mask + Luhn-check PAN client-side, and never log PAN/CVV. They reject regex-based sanitizers categorically.

Related questions