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
Vaulting keeps you at SAQ A only if a few things hold: PANs never hit your app, you store only opaque references, network tokenization is enabled, and lifecycle webhooks are idempotent. The stack lists the foundations.
The word “only” in “opaque token ids and non-sensitive metadata” is doing real work. The moment a single column, log line, or exception payload contains a value that a Luhn check and a 4[0-9]{15} or 5[1-5][0-9]{14} regex would classify as a PAN, your scope inflates from SAQ A to SAQ A-EP or worse, and the annual assessment stops being a self-questionnaire and starts requiring an on-site QSA. That is not a paperwork nuance — it is the difference between an engineer signing a form and a six-figure audit engagement. So the prerequisite is not merely “store tokens”; it is “have a mechanical guarantee that a PAN cannot land anywhere in your infrastructure”, which is why the PAN-regex guard test appears again in the Testing section. Storing last4 and exp_year is fine because four digits plus an expiry cannot reconstruct a chargeable card; storing the middle six digits (the old BIN-plus-account convention) is not, because the network-assigned ranges make truncation reversible in aggregate.
What “non-sensitive metadata” may and may not include
Brand, last4, expiry month and year, the issuing country, and the funding type (credit, debit, prepaid) are all safe to persist and are genuinely useful: funding type drives surcharge eligibility in the jurisdictions that permit it, and issuing country feeds the tax-jurisdiction re-evaluation described later. What you must never persist, cache, or log is the full PAN, the CVV/CVC (which PCI-DSS forbids storing after authorization under any circumstance, tokenized or not), the full magnetic-stripe or chip track data, or the PIN block. The CVV rule catches teams off guard because it feels harmless to keep “just for the first charge” — but the standard is categorical, and a CVV found in a database dump is an automatic finding regardless of encryption. Design the capture flow so the CVV is transmitted straight from the hosted field to the PSP and never round-trips through your backend at all.
Encryption-at-rest is necessary but not sufficient
AES-256-GCM on the mapping table protects you against a stolen disk or a leaked backup, but it does nothing against an application-layer compromise that queries the table with legitimate credentials — and since the stored values are already opaque references, the encryption is defense-in-depth rather than the primary control. Treat the psp_token and network_token_ref columns as low-value on their own: an attacker who exfiltrates them cannot charge a card, because the tokens are bound to your merchant identity at the PSP and network. The real crown jewels are the API credentials that let you use those tokens, so scope your secrets management and IAM around the charge-issuing service account far more tightly than around the mapping table itself.
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.
The two-token model, and why you store both
It is tempting to collapse psp_token and network_token_ref into a single column, but they answer different questions and fail independently. The PSP vault token is your PSP’s internal handle — it is what you pass to charges.create or paymentIntents.create, it is portable only within that PSP, and it survives as long as your PSP relationship does. The network token is issued by Visa Token Service or Mastercard MDES, is cryptographically bound to your merchant identifier, and carries its own expiry and its own lifecycle that the network manages on the issuer’s behalf. In most integrations the PSP transparently uses the network token under the hood when you charge the PSP token, so you rarely reference network_token_ref directly — but you keep it because it is the artifact that survives a PSP migration. If you ever move from one processor to another, the PSP vault tokens are worthless at the destination, whereas the network token (or a network-token-provisioning handshake) is the mechanism that lets the new PSP recognize the same underlying card without a re-entry campaign. Storing both costs one nullable TEXT column and buys you a migration path that does not touch a single customer.
Off-session charging and the credential-on-file mandate
Recurring billing is an “off-session” charge in the card-network vocabulary: the cardholder is not present to complete a challenge. To keep authorization rates high and stay compliant with the networks’ stored-credential rules, the first transaction must be flagged as the one that establishes the credential-on-file agreement, and every subsequent charge must reference that initial transaction. Concretely, you capture and persist the initial_transaction_id (Visa calls it the transaction identifier; Mastercard threads it through the trace ID) returned on the setup charge, then send it back on each renewal as the stored-credential reference. Issuers use that linkage to distinguish a legitimate merchant-initiated renewal from a fresh cardholder-absent transaction they have every reason to decline. Skipping this is one of the most common causes of an otherwise-healthy subscription seeing its auth rate sag by several points after the first month — the card is fine, the token is fine, but each charge looks to the issuer like an unauthenticated stranger. Store the initial transaction reference alongside the token, treat it as immutable for the life of the mandate, and only mint a new one when the customer explicitly re-consents (a new checkout, not a silent token rotation).
Where the ledger boundary sits
The mapping table is deliberately not the ledger. The payment_method table holds mutable state — a token can be updated in place ten times over a card’s life — whereas the ledger is append-only and records what was charged against which token at a point in time. When you write a charge to the ledger, snapshot the payment_method_id and enough display metadata (brand, last4) into the ledger row so that a later token rotation does not retroactively rewrite what the customer sees on a two-month-old invoice. This separation is what lets the nightly reconcile be a pure diff: the ledger says “on the 3rd we charged 4999 minor units against payment_method X showing last4 4242”, and the mapping table says “payment_method X currently points at psp_token Y” — and reconciliation checks that Y still exists and still corresponds to a card ending 4242, flagging the row if the PSP has since reported the token deleted.
Implementation Walkthrough
The four steps tokenize once, enroll in network tokenization, apply lifecycle events idempotently, then reconcile nightly. The payoff is that the card, captured once, stays chargeable through reissues without the customer ever re-entering it. The lifecycle diagram shows a reissued card pushing an update before the next charge.
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()
);
The UUID PRIMARY KEY on payment_method_id matters more than it looks. Never expose the psp_token value as your own identifier — if you key subscriptions, ledger rows, and portal URLs off the PSP token directly, then a token rotation cascades a primary-key change through half your schema. The internal payment_method_id is stable for the life of the card; the psp_token behind it is free to change. That indirection is the single most important schema decision on this page: everything downstream references the surrogate key, and the volatile vendor handle lives in exactly one place.
One subtlety in the frontend snippet: createPaymentMethod returns before the card is confirmed usable for off-session charges. For a recurring subscription you generally want a SetupIntent flow instead, which performs the initial authentication (including any 3-D Secure challenge) and produces a payment method already authorized for future off-session use. Tokenizing without that setup step leaves you holding a token that may fail its very first background charge because the issuer wanted a challenge the customer was never shown. Capture the card, run the SetupIntent, persist the resulting token only after it reaches the succeeded state, and record the authentication outcome so the first renewal can assert it was set up correctly.
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.
Enrollment is usually a single account-level toggle at the PSP plus a merchant onboarding step the PSP handles with the networks on your behalf, but the operational consequence is that your token now has two independent expiry clocks. The PAN behind it has the printed expiry your customer sees; the network token itself has a cryptographic validity window managed by VTS/MDES. Most of the time these move together, but a card can be reissued (new PAN, new printed expiry) while the network token reference stays stable and simply gets repointed at the new PAN underneath — which is precisely the mechanism that makes account-updater “just work”. The practical takeaway: do not treat your stored exp_month/exp_year as authoritative for deciding whether a charge will succeed. They are display metadata for the customer’s benefit. The network knows the real state, and the only reliable way to learn it is to attempt the charge or subscribe to the push updates — never to compare the stored expiry against now() and preemptively suppress a charge you assume will fail. That kind of client-side expiry guard silently churns customers whose cards were quietly reissued behind a stable token.
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);
});
}
The isStaleSequence check deserves a closer look, because “sequence” here is not a global counter — it is per-customer, and ideally per-payment-method. Account-updater and rotation events for different customers are wholly independent, so a single monotonic sequence across all customers would force artificial ordering and create a hot contention point. Store the last-applied sequence keyed by customer_id (or better, payment_method_id), compare only within that key, and let unrelated customers’ updates flow through in any order. The comparison itself must be > not >=: two deliveries of the same event carry the same sequence, and the isProcessed(event.id) dedupe on the event id is what collapses those, while the sequence check only guards against a genuinely older update arriving late. Getting these two guards confused — using the sequence to dedupe, or the event id to order — is a classic source of “the token flickered back to a stale value for one charge cycle” bugs that are maddening to reproduce because they depend on webhook delivery race conditions you cannot easily replay in staging.
Note also that the whole handler runs inside a single database transaction. That is not incidental. If you markProcessed outside the transaction that does the UPDATE, a crash between the two writes leaves the event recorded as processed while the token was never actually changed — and because the event is now deduped, no retry will ever fix it. Bind the dedupe record, the sequence record, and the token mutation into one atomic commit so the system has exactly two states: the whole update applied, or none of it did and the PSP’s retry will deliver it again.
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.
Reconciliation earns its keep on the failures the webhook path silently drops. Webhooks are best-effort: a delivery can exhaust its retry budget during a multi-hour outage on your side, a signature-verification bug can reject a whole class of events for a day before anyone notices, or a deploy can leave the consumer down long enough to miss a burst. Any of these leaves your mapping table pointing at a token the PSP has already retired, and you will not discover it until a charge declines. The nightly diff is the backstop: it pulls the current vault state for every active payment_method (in pages, against the PSP’s list endpoints or an exported snapshot), compares psp_token and the display metadata, and emits a reconciliation event for every mismatch rather than fixing it inline. Keep the job read-only against production and let it feed the same idempotent update handler that webhooks use, so there is one code path that mutates a token and one set of invariants to reason about. Log every discrepancy with the customer_id, the stale token, and the authoritative token so that a spike in reconciliation events becomes an early warning that the webhook pipeline has regressed — the count of nightly corrections is one of the most honest health metrics you have for the whole vaulting subsystem.
Edge Cases & Failure Modes
The vaulting edge cases split into timing/ordering of token updates, token-invalid declines, and cross-border/migration constraints. The map groups them so the defense — sequence numbers, decline mapping, or region-aware migration — is obvious.
| 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 |
The webhook-lands-mid-cycle race in detail
The first row of that table is the one that generates the most confusing production incidents. Picture a subscription that renews at 02:00 UTC and an account-updater push that arrives at 02:00:03 — three seconds after the billing job already read the old token, attempted the charge, and got a decline. The customer’s card is perfectly good; the update was in flight; and the naive system now marks the invoice failed and starts dunning a customer who did nothing wrong. The defensive posture is to treat a decline on a scheduled charge as provisional when there is an in-flight or recently-applied token update for that customer. Concretely: before you escalate a failed renewal into the dunning ladder, check whether a token update for the same payment_method_id landed within a short window (a few minutes) of the charge attempt, and if so, requeue the charge against the fresh token before sending any “payment failed” email. This costs one indexed lookup on an update-log table and eliminates a whole category of self-inflicted involuntary churn. Pair it with a small delay between the charge attempt and the first dunning message so the webhook queue has time to drain — the few minutes of latency you add are invisible to the customer and save a support ticket.
Deleted-then-recreated and the resurrection problem
A subtler edge case: a customer removes their card in the portal (you delete the payment_method row and revoke the PSP token), then re-adds what is physically the same card an hour later. Network tokenization may hand you back the same network_token_ref because the underlying PAN is unchanged, while the PSP mints a fresh vault token. If your reconcile or dedupe logic keys off the network token reference alone, it can resurrect the deleted mapping or collide with the new one. Key the customer-facing identity off your own payment_method_id, treat delete as a soft-delete with a deleted_at timestamp rather than a hard row removal, and let re-adds create a new row — so the ledger’s historical references to the old payment_method_id stay intact and the resurrection never happens.
Performance & Scale
Vaulting’s performance profile is gentle: a small read-heavy mapping table on the charge path, push-driven update traffic, and one heavy nightly reconcile run on a replica. The diagram shows where the load sits.
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
The tests prove idempotency, ordering, signature rejection, and — the cheapest insurance of all — that no column ever holds a value matching a PAN regex. The panel lists them before the detail.
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.
Should I store the CVV so the first recurring charge succeeds? No, never — and this is one of the few absolutes in PCI-DSS. You may not store the CVV/CVC after authorization under any circumstance, encrypted or not, tokenized or not. Recurring charges do not use the CVV anyway; the stored-credential framework and the network token are what authorize an off-session renewal. If a PSP integration seems to want the CVV on every charge, you are modeling one-off cardholder-present payments, not a subscription. Route the CVV from the hosted field straight to the PSP on the initial setup and let it never touch your backend.
Can I move my vaulted cards to a new PSP without asking customers to re-enter them?
Usually yes, through a controlled migration that never exposes raw PANs to you. The cleanest path is network tokens: because they are bound to your merchant identity at the network rather than to a specific processor, the new PSP can provision against the same underlying credential. Absent that, most large PSPs support a QSA-supervised bulk token export directly to the destination processor’s PCI-certified environment — the cards move vault-to-vault, and your systems only ever see the new opaque tokens. Budget weeks, not days: the networks and both PSPs sign off, and you run the two vaults in parallel while you re-point psp_token values behind your stable payment_method_id surrogate keys.
Why did my authorization rate drop a few points after the first month even though nothing changed?
Almost always a missing stored-credential linkage. If the initial charge did not establish a credential-on-file agreement, or renewals do not reference the initial_transaction_id from that setup, each subsequent charge looks to the issuer like an unauthenticated cardholder-absent transaction and gets declined at a higher rate. The card and token are fine; the transaction metadata is not. Persist the initial transaction reference at setup, send it on every renewal, and the auth rate recovers.