Secure Card Vaulting & Tokenization

Vaulting is what lets you charge a customer next month without ever seeing their card again β€” and doing it correctly is the difference between an SAQ A audit and a breach disclosure. This page sits under Frontend Checkout UX & Dunning Recovery Flows and covers the architecture for storing a card as a token: how the card is captured (via Payment Element Integration) so no raw PAN reaches your servers, the difference between PSP vault tokens and network tokens, how the token lifecycle (rotation, expiry, account updater) is kept in sync with your ledger, and what PCI-DSS still demands once the PAN is gone.

The core principle: you store references, never card data. A PSP payment-method id or a network token is opaque β€” losing it does not expose a card. But a token is not static. Cards expire, get reissued after fraud, and rotate under issuer programs, and every one of those events is an asynchronous signal you must apply before the next scheduled charge or you generate involuntary churn.

Prerequisites

Architecture & Data Flow

Capture happens in the PSP’s iframe; the PSP returns an opaque token; your backend stores only that token plus display metadata. For recurring charges you reference the token off-session. Network tokenization adds a layer: the card networks issue a network token bound to the merchant, and when the underlying card is reissued the network pushes an updated token automatically β€” raising authorization rates and eliminating most expiry-driven failures.

Vaulting and token lifecycle flow A card is captured in a PCI-certified hosted field, exchanged for a PSP vault token and a network token, and lifecycle updates are reconciled against the ledger before the next charge. Hosted field (PAN entry) PSP vault + network token Token mapping table (opaque) Account updater webhook Billing ledger + next charge Nightly reconcile token issuer push
Only opaque tokens are stored; issuer-pushed updates are reconciled before the next scheduled charge.

Inputs: a card captured once. Processing: exchange for tokens, apply lifecycle events idempotently. Outputs: a mapping table of opaque references and a ledger that always charges against the current token.

Implementation Walkthrough

1. Tokenize in the hosted field, store only the reference

The browser exchanges the card for a token; your server receives an opaque id. Storing or logging the PAN would put you back in full PCI scope.

// Frontend β€” never handles raw card numbers server-side
async function tokenize(stripe: Stripe, element: StripePaymentElement): Promise<string> {
  const { paymentMethod, error } = await stripe.createPaymentMethod({ elements });
  if (error) throw new Error(`tokenization_failed: ${error.message}`); // βœ— no PAN persisted
  return paymentMethod.id; // opaque β€” safe to send to your server
}
-- Server stores references and display metadata only β€” no PAN, no CVV ever
CREATE TABLE payment_method (
  payment_method_id  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id        UUID NOT NULL REFERENCES customer(customer_id),
  psp_token          TEXT NOT NULL,            -- opaque PSP vault id
  network_token_ref  TEXT,                     -- opaque network token reference
  brand              TEXT NOT NULL,
  last4              CHAR(4) NOT NULL,          -- display only
  exp_month          SMALLINT NOT NULL,
  exp_year           SMALLINT NOT NULL,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);

2. Enroll in network tokenization

Enabling network tokens (Visa VTS, Mastercard MDES) at the PSP means the network issues a merchant-bound token and pushes updates when the card is reissued β€” typically lifting auth rates 1–3 points and removing most expiry-driven declines on recurring charges.

3. Apply lifecycle events idempotently

Account-updater and rotation events arrive as webhooks, possibly out of order. Dedupe on the event id and reject stale sequences so an older update never overwrites a newer token.

async function handleTokenUpdate(event: {
  id: string;
  payload: { customer_id: string; new_psp_token: string; sequence: number };
}): Promise<void> {
  const { customer_id, new_psp_token, sequence } = event.payload;
  await db.transaction(async (tx) => {
    if (await tx.isProcessed(event.id)) return;            // βœ… duplicate β†’ no-op
    if (await tx.isStaleSequence(customer_id, sequence)) return; // ⚠️ out-of-order β†’ drop
    await tx.query(
      `UPDATE payment_method SET psp_token = $2 WHERE customer_id = $1`,
      [customer_id, new_psp_token],
    );
    await tx.recordSequence(customer_id, sequence);
    await tx.markProcessed(event.id);
  });
}

4. Reconcile nightly

A diff job matches the mapping table against the PSP’s vault state and against ledger entries; any drift (a token charged that the PSP reports deleted) triggers an immediate reconciliation and a customer-portal update prompt.

Edge Cases & Failure Modes

Failure scenario Mitigation
Token update webhook lands after the billing cycle starts Delay dunning until webhook queues drain; fall back to status polling
Out-of-order rotation overwrites a newer token Per-customer sequence numbers; reject stale sequences
Silent token-invalid decline on a scheduled retry Map issuer decline codes to a portal β€œupdate card” prompt immediately
Vault update changes the billing country Re-evaluate tax jurisdiction on update; adjust before the cycle closes
Cross-border vault migration hits data-residency rules Keep tokens in the region of issuance; migrate only with documented residency review
PSP migration requires moving stored cards Use network tokens or a PCI-scoped token-export (most PSPs support a vetted migration) β€” never export raw PANs

Performance & Scale

The token mapping table is small and read-heavy; index payment_method(customer_id) and keep display metadata denormalized so the charge path needs one lookup. Network-token enrollment shifts most update traffic to push webhooks, so the polling cadence can drop to daily as a safety net rather than the primary mechanism. The nightly reconcile is the expensive job β€” batch it in chunks of a few thousand customers and run it against a read replica so it never contends with live charges. At 100k stored cards, expect a few hundred account-updater events per day, well within a single idempotent consumer’s throughput.

Testing Strategy

Replay each token-update event twice and assert exactly one mutation β€” that proves idempotency. Submit an out-of-order pair (sequence 5 then sequence 4) and assert the table still holds the sequence-5 token. Forge a webhook with the wrong signing secret and assert rejection. Use a mock clock to test that the reconcile job flags a token the PSP reports deleted. Assert that no test ever persists a value matching a PAN regex β€” a guard test on the payment_method table is cheap insurance against a regression that leaks card data into a column.

Frequently Asked Questions

Network tokens or PSP vault tokens β€” which should I prefer? Prefer network tokens where available. They are issued by the card networks, bound to your merchant identity, raise authorization rates, and update automatically when a card is reissued. PSP vault tokens are the fallback and are also fine, but they do not get issuer-pushed lifecycle updates, so you lean harder on account-updater programs and polling.

Does tokenization remove my PCI-DSS obligations? No β€” it shifts and shrinks them. With hosted capture you typically land at SAQ A, but you still owe access controls, TLS, audit logging of token create/rotate/delete events, and encryption-at-rest on the mapping table. Tokenization moves the PAN out of scope; it does not move you out of scope.

What happens to a subscription when its token expires? If you do nothing, the next charge fails and the customer churns involuntarily. The fix is proactive: network-token push updates handle most reissues automatically, and for the rest you poll token status and surface a self-service update prompt before the scheduled charge β€” wired into your grace period and retry logic.

How do I keep the ledger consistent with asynchronous vault webhooks? Process vault events with an idempotent consumer and sequence tracking, then run a nightly reconcile that matches token state against ledger entries. The ledger should always charge against the current token, and any drift between vault and ledger triggers an immediate job rather than waiting for the next cycle.