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.
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.