WooCommerce Payment Gateway Development: Keeping Card Data Off WordPress
Most custom WooCommerce payment gateway development starts with the same question: where does the card number go? A gateway that renders card fields inside the WordPress checkout form puts the primary account number (PAN) in a $_POST array on a server that also runs dozens of third-party plugins. That makes the whole WordPress install part of the cardholder-data environment. It's the fastest way to make PCI compliance on WooCommerce expensive. The patterns we have built for WooCommerce stores all start from the opposite assumption: the card number never reaches WordPress at all. This post walks through two ways we've done that, the signing and origin checks that hold them together, and what it takes to support both the classic checkout and WooCommerce Checkout Blocks.
Two architectures for tokenized payments
Both designs move card entry off the WordPress server. They differ in who owns the charge.
A vault with a hosted iframe. The checkout page embeds an iframe served by a separate card vault. The customer types the card into that iframe, the vault tokenizes it, and the iframe tells the parent page the token and some masked metadata. WordPress stores the token plus brand, last four digits and expiry, and nothing else. Charging happens out of band, so the gateway places the order on-hold with the note "Awaiting capture/processing in external system."
A separate payment service. WordPress hands the order to a small Laravel application. Laravel shows the card form, charges the card through the payment provider, and calls WordPress back to mark the order paid. WordPress never renders a card field at any point in the flow.
The first design suits stores that already have, or want, a vault and an internal admin panel that processes payments later. The second suits stores that want charging to happen immediately but still want the card form living somewhere other than WordPress.
The vault pattern: the iframe and the origin check
The gateway's payment_fields() renders an empty container and five hidden inputs: wcvtg_vault_token, brand, last4, expiry month and expiry year. The iframe itself is injected by JavaScript, so the plugin controls its attributes:
var iframe = document.createElement("iframe");
iframe.src = src;
iframe.setAttribute("referrerpolicy", "no-referrer");
iframe.setAttribute("sandbox", "allow-forms allow-scripts allow-same-origin");
The vault reports the result with postMessage, and this is where most of the security lives. Any window can post a message to the checkout page, so the listener discards anything that isn't from the configured vault origin before reading a single field:
window.addEventListener("message", function (ev) {
if (!origin || ev.origin !== origin) return; // exact match: scheme + host + port
var data = ev.data;
if (data && data.type === "vault.tokenized") {
latestTokenMeta = {
token: data.token,
brand: data.brand,
last4: data.last4,
expMonth: data.expMonth,
expYear: data.expYear,
};
applyTokenMeta(latestTokenMeta);
}
});
The comparison is strict equality against a full origin. A prefix or substring check would accept a lookalike host. The settings screen tells the admin exactly what to enter: "Exact origin that will postMessage() back (protocol + host + optional port)."
One WooCommerce-specific detail: the classic checkout re-renders the payment section on updated_checkout, which wipes the hidden inputs. The script caches the last tokenization result and re-applies it on every re-mount and again on checkout_place_order, so a customer who changes their shipping address after entering a card doesn't silently lose the token.
Store the token, refuse the PAN
On the server, validate_fields() does two things. It requires a token, and it rejects the request outright if any raw card field shows up:
foreach (['card_number','card_cvc','card_expiry','cc-number','cc-cvc','cc-expiry'] as $raw) {
if (!empty($_POST[$raw])) {
wc_add_notice(__('Direct card entry is not allowed. Use the secure form.', 'wcvtg'), 'error');
return false;
}
}
That check should never fire in normal use. It exists so that a future theme override or a well-meaning checkout-field plugin can't quietly reintroduce a PAN into the WordPress request path. process_payment() then writes only _vault_token, _card_brand, _card_last4, _card_exp_month and _card_exp_year to the order, adds an audit note in the form "Card: visa •••• 4242 (10/2030)", and returns. The order admin screen gets a button that links to the external payment panel with the order ID substituted in, and that panel works from the stored token.
The design documentation specifies that the link between checkout and vault runs over HTTPS with mutual TLS. The vault is the one system that ever holds a PAN, so it gets the strongest transport requirements.
Webhooks back into WooCommerce
A vault token can change after checkout. Cards get reissued and tokens get invalidated. The plugin exposes POST /wp-json/wc-vault/v1/webhook for that. The route is public by necessity, so authentication is a signature over the raw request body:
if (!$secret || !$sig || !hash_equals($sig, hash_hmac('sha256', $body, $secret))) {
return new \WP_REST_Response(['ok' => false, 'error' => 'unauthorized'], 401);
}
Three details matter here. The signature is computed over get_body(), the exact bytes received, not over re-encoded JSON. The comparison uses hash_equals, which is constant-time. And an empty configured secret fails closed instead of accepting everything. The settings sanitiser also refuses to save a webhook secret shorter than 64 characters and keeps the previous value when it rejects one, so a mistyped paste can't downgrade the store to a weak secret.
A valid token_updated event refreshes the masked fields on every order carrying that token. A token_invalidated event adds an order note. In both cases, the only card data that ever crosses into WordPress is data that was already safe to display.
The payment-service pattern: HMAC in both directions
In the Laravel variant, the gateway builds a small order payload (ID, order key, total, customer name and email), signs it, and posts it:
$payload_json = wp_json_encode($full_payload, JSON_UNESCAPED_SLASHES);
$signature = hash_hmac('sha256', $payload_json, $shared_secret);
wp_remote_post("{$laravel_url}/cardpayments", [
'headers' => ['Content-Type' => 'application/json', 'X-WP-Signature' => $signature],
'body' => $payload_json,
]);
Laravel verifies the signature in middleware against $request->getContent(), stores the order as pending, and returns a random token. WordPress redirects the customer to a /securepay/{token} page, which shows the Laravel card form in a full-page iframe overlay. After a successful charge, Laravel posts back to a WordPress REST callback, signed the same way with the same X-WP-Signature header. WordPress verifies it with hash_equals against the raw body, calls payment_complete(), and returns the order-received URL.
The contract we wrote down for this integration has one line in bold: the signature is computed over the exact raw JSON string. Both sides encode with unescaped slashes, and both sides verify against the raw body, never against a decoded-and-re-encoded copy. Most "signature mismatch" bugs in two-system payment flows come from breaking that rule. PHP's default JSON encoder escapes / as \/, so any URL in the payload changes the bytes.
One interface, many providers
On the Laravel side, charging goes through a single interface:
interface PaymentGateway
{
public function charge(PaymentRequest $request): PaymentResult;
public static function providerKey(): string;
}
A factory picks the implementation from config (Stripe by default, with a PayPal adapter alongside it) and throws on an unknown provider instead of falling back silently. PaymentResult is an immutable DTO with named constructors for success and failure. The WooCommerce plugin knows nothing about any of this. Adding or switching a payment processor is a change to the Laravel service, with no plugin release needed across the store.
Supporting classic checkout and Checkout Blocks
A gateway built only on WC_Payment_Gateway works on the classic shortcode checkout. It doesn't appear on the block-based checkout. Supporting both means a second, small registration layer, which we built on another WooCommerce gateway:
add_action('woocommerce_blocks_loaded', function () {
if (!class_exists('Automattic\\WooCommerce\\Blocks\\Payments\\Integrations\\AbstractPaymentMethodType')) {
return;
}
// ...register a class extending AbstractPaymentMethodType with the payment method registry
});
The integration class has three jobs. is_active() reads the same woocommerce_{id}_settings option as the classic gateway, so a single settings screen drives both checkouts. get_payment_method_script_handles() registers a script that depends on wc-blocks-registry, wc-settings and wp-element. get_payment_method_data() passes the title, description and supported features to the front end. The script reads those settings and calls registerPaymentMethod() with a label, content and edit components, and a canMakePayment callback.
The class-exists guard matters. It keeps the plugin working on stores that run the classic checkout or an older WooCommerce. For an iframe-based gateway, plan the Blocks side as real work, not a checkbox. The classic path above relies on jQuery checkout events such as updated_checkout and checkout_place_order, and the block checkout doesn't fire those. The iframe mount and the token hand-off have to live inside the Blocks payment component.
What this buys you
None of this makes a store "PCI compliant" by itself. Compliance is a process, not a plugin. What these patterns do is keep the card number off the server you have the least control over, and that is the single biggest factor in how much of your stack PCI compliance for WooCommerce has to cover. The rest is disciplined engineering: exact-origin checks, signatures over raw bytes, constant-time comparisons, fail-closed defaults, and a charging layer you can change without touching checkout.
If you're planning a custom gateway, or you need an existing one moved off direct card entry, our ecommerce development team builds these integrations end to end, and our WordPress plugin development team handles the WooCommerce side, classic and Blocks alike. Get in touch and we'll look at where card data flows in your checkout today.