Notes

stripe.confirmSetup confirms the card before your server sees the rest of the form

Published

stripe.confirmSetup confirms the SetupIntent from the browser. When a signup form has a Payment Element next to your own fields, the card is confirmed before your server has looked at those fields. If the server then refuses the signup, the card has already been set up.

Stripe’s reference for confirmSetup describes the call: it confirms the SetupIntent with the data collected by the Payment Element and completes any required action, such as a 3D Secure dialog. When the SetupIntent belongs to a customer, a successful setup attaches the payment method to that customer.

The order that goes wrong

While validating a signup flow built on the Payment Element, I submitted the form with the password left blank. The SetupIntent was confirmed at Stripe, and only then did the account endpoint refuse the request.

The password rule existed only on the server. Any rule that only the server enforces ends the same way: the customer’s card is confirmed, possibly through a 3D Secure challenge, for an account that is never created.

Validate first, then confirm

In this sketch, validateAccountFields, showError and createAccount are placeholders for your own functions:

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  // Every rule the account endpoint enforces, checked before Stripe is called.
  const problem = validateAccountFields(form);
  if (problem) {
    showError(problem);
    return;
  }

  const { error, setupIntent } = await stripe.confirmSetup({
    elements,
    confirmParams: { return_url: 'https://example.com/signup/complete' },
    redirect: 'if_required',
  });
  if (error) {
    showError(error.message);
    return;
  }

  await createAccount(form, setupIntent.id);
});

The client check has to match the server’s rule exactly. Laravel’s required rule trims a string before testing it, so a password made of spaces fails on the server. The browser check needs trim() as well, or that password passes the form and brings the bug back.

The test for the fix asserts that confirmSetup was not called when the password is blank. I watched it fail for that reason before the fix, and afterwards I broke the fix on purpose to see it fail again.

The server keeps validating everything. The client check only spares the customer a card confirmation that leads nowhere. After the fix I went through the form field by field and asked which rules only the server knows, because each one is another way to confirm a card for nothing.