A payment submit button that stays enabled during an in-flight request lets users double-click or rapidly resubmit, dispatching concurrent payment requests for the same cart. Even if you use idempotency keys on the backend, two concurrent requests before the first response arrives may each generate a separate key — resulting in two distinct charges. Beyond accidental double-clicks, an enabled button during processing provides no user feedback, increasing anxiety and intentional resubmission. CWE-362 (Race Condition) and CWE-799 (Improper Control of Interaction Frequency) both apply. The fix is a two-layer guard: UI state disabling the button immediately on first submission, and backend idempotency preventing duplicates if the UI guard is bypassed.
High because an always-enabled submit button under network latency allows concurrent submissions that can produce duplicate charges before the idempotency layer has a chance to deduplicate them.
Set an isProcessing flag on the first submission click and disable the button until the round-trip completes or fails.
function PaymentForm() {
const [isProcessing, setIsProcessing] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (isProcessing) return // synchronous guard for rapid double-click
setIsProcessing(true)
try {
const { error, paymentIntent } = await stripe!.confirmPayment({ /* ... */ })
if (!error && paymentIntent?.status === 'succeeded') {
router.push('/order/complete')
} else if (error) {
setError(error.message)
}
} finally {
setIsProcessing(false)
}
}
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={isProcessing} aria-busy={isProcessing}>
{isProcessing ? 'Processing…' : 'Pay Now'}
</button>
</form>
)
}
The finally block ensures the button re-enables on errors, letting users correct card details and resubmit without refreshing the page.
ID: ecommerce-payment-security.client-side-handling.duplicate-payment-prevention
Severity: high
What to look for: Check the checkout submit handler for UI-level duplicate prevention: look for a isLoading, isSubmitting, or isProcessing state that disables the submit button during the payment request. Count all payment submit buttons and for each verify it has a disabled state tied to an in-flight request. Also check backend payment routes for idempotency key usage (covered separately) and for any server-side check that a PaymentIntent or order for this checkout session has not already been confirmed.
Before evaluating: Quote the submit button JSX and its disabled prop binding. Example: "Found <button disabled={isProcessing}>Pay</button> in components/checkout/PaymentForm.tsx."
Pass criteria: Duplicate payment submission is prevented at the UI layer (submit button disabled during in-flight request) AND at the backend layer (idempotency keys or session-based locks prevent processing the same checkout twice). No more than 0 payment submit buttons should lack a disabled state.
Fail criteria: The submit button remains enabled during payment processing, allowing multiple rapid clicks to dispatch concurrent payment requests. No UI state management prevents double-submission.
Skip (N/A) when: Never — this check applies to all payment flows with a user-facing submit action.
Detail on fail: Describe the missing prevention. Example: "'Pay Now' button in components/checkout/PaymentForm.tsx has no disabled state during payment processing — rapid double-click can fire two concurrent payment requests"
Remediation: Implement duplicate prevention at the UI layer:
function PaymentForm() {
const [isProcessing, setIsProcessing] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (isProcessing) return // Guard against concurrent calls
setIsProcessing(true)
try {
const { error } = await stripe!.confirmPayment({ /* ... */ })
if (!error) router.push('/order/complete')
else setError(error.message)
} finally {
setIsProcessing(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
<button type="submit" disabled={isProcessing}>
{isProcessing ? 'Processing...' : 'Pay Now'}
</button>
</form>
)
}