Test 3D Secure (3DS) payments in Stripe using test card 4000000000003220 (triggers 3DS that can succeed or fail) and 4000008400001629 (always fails 3DS). Your PaymentIntent will return status requires_action when 3DS is needed, and your frontend must redirect the customer to complete authentication before the payment can succeed.
What Is 3D Secure and Why Test It?
3D Secure (3DS) is an authentication protocol that adds an extra verification step during online card payments. The customer is redirected to their bank to confirm the payment with a password, SMS code, or biometric. Under European SCA (Strong Customer Authentication) regulations, 3DS is required for most card payments. Stripe's Radar rules may also trigger 3DS for high-risk transactions. Testing 3DS ensures your checkout flow handles the authentication redirect, success callback, and failure fallback correctly.
Prerequisites
- A Stripe account in test mode
- Node.js 18 or newer installed
- The stripe npm package on the server and @stripe/stripe-js on the frontend
- Your test API keys (pk_test_ and sk_test_) from Dashboard → Developers → API keys
Step-by-step guide
Create a PaymentIntent on the server
Create a PaymentIntent on the server
Create a PaymentIntent with confirm: true and a 3DS test card. The response will have status requires_action when 3DS is triggered.
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);23const paymentIntent = await stripe.paymentIntents.create({4 amount: 5000, // $50.005 currency: 'usd',6 payment_method: 'pm_card_threeDSecure2Required',7 confirm: true,8 return_url: 'https://yoursite.com/payment-result',9});1011console.log('Status:', paymentIntent.status);12// 'requires_action' — 3DS authentication neededExpected result: PaymentIntent status is requires_action with a next_action object containing the 3DS redirect URL.
Handle 3DS on the frontend with Stripe.js
Handle 3DS on the frontend with Stripe.js
Use stripe.confirmCardPayment() on the frontend, which automatically handles the 3DS popup or redirect. Stripe.js manages the authentication flow and returns the result.
1// Frontend JavaScript2const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');34async function handlePayment(clientSecret) {5 const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret);67 if (error) {8 // 3DS failed or card declined after 3DS9 console.error('Payment failed:', error.message);10 document.getElementById('error').textContent = error.message;11 } else if (paymentIntent.status === 'succeeded') {12 console.log('Payment succeeded!');13 window.location.href = '/success';14 }15}Expected result: A 3DS authentication popup appears. Clicking 'Complete' succeeds; clicking 'Fail' returns an error.
Test with cards that always require 3DS
Test with cards that always require 3DS
Use these test cards in your Stripe Elements form to trigger different 3DS scenarios.
1// Always requires 3DS, authentication can succeed or fail:2// Card: 40000000000032203// Token: pm_card_threeDSecure2Required45// Requires 3DS, authentication always fails:6// Card: 400000840000162978// Requires 3DS, authentication succeeds:9// Card: 40000000000030551011// 3DS is supported but not required (Radar may trigger it):12// Card: 40000000000030631314// Use these with any future expiry, any 3-digit CVC, any postal codeExpected result: Each card triggers the appropriate 3DS behavior in Stripe's test environment.
Verify the result via webhook
Verify the result via webhook
Set up a webhook to listen for payment_intent.succeeded and payment_intent.payment_failed events. This is the most reliable way to confirm the outcome after 3DS.
1const express = require('express');2const app = express();34app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {5 const sig = req.headers['stripe-signature'];6 let event;78 try {9 event = stripe.webhooks.constructEvent(10 req.body,11 sig,12 process.env.STRIPE_WEBHOOK_SECRET13 );14 } catch (err) {15 return res.status(400).send(`Webhook Error: ${err.message}`);16 }1718 if (event.type === 'payment_intent.succeeded') {19 console.log('Payment confirmed after 3DS:', event.data.object.id);20 } else if (event.type === 'payment_intent.payment_failed') {21 console.log('Payment failed after 3DS:', event.data.object.id);22 }2324 res.json({ received: true });25});Expected result: Webhook receives the succeeded or payment_failed event after the customer completes or fails 3DS authentication.
Complete working example
1const express = require('express');2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);34const app = express();5app.use(express.static('public'));67// Create PaymentIntent for 3DS testing8app.post('/api/create-payment', express.json(), async (req, res) => {9 try {10 const { amount } = req.body;1112 const paymentIntent = await stripe.paymentIntents.create({13 amount: amount || 5000,14 currency: 'usd',15 automatic_payment_methods: { enabled: true },16 });1718 res.json({ clientSecret: paymentIntent.client_secret });19 } catch (err) {20 res.status(500).json({ error: err.message });21 }22});2324// Webhook to confirm 3DS result25app.post('/webhook',26 express.raw({ type: 'application/json' }),27 (req, res) => {28 const sig = req.headers['stripe-signature'];29 let event;3031 try {32 event = stripe.webhooks.constructEvent(33 req.body,34 sig,35 process.env.STRIPE_WEBHOOK_SECRET36 );37 } catch (err) {38 console.error('Webhook signature verification failed:', err.message);39 return res.status(400).send(`Webhook Error: ${err.message}`);40 }4142 switch (event.type) {43 case 'payment_intent.succeeded':44 console.log('Payment succeeded (post-3DS):', event.data.object.id);45 break;46 case 'payment_intent.payment_failed':47 console.log('Payment failed (post-3DS):', event.data.object.id);48 break;49 case 'payment_intent.requires_action':50 console.log('Awaiting 3DS authentication:', event.data.object.id);51 break;52 }5354 res.json({ received: true });55 }56);5758const PORT = process.env.PORT || 3000;59app.listen(PORT, () => console.log(`Server on port ${PORT}`));Common mistakes when testing 3D Secure payments in Stripe
Why it's a problem: Not handling the requires_action status on the frontend
How to avoid: When a PaymentIntent returns requires_action, you must call stripe.confirmCardPayment() with the client_secret to trigger the 3DS popup. Ignoring this status means the payment never completes.
Why it's a problem: Using stripe.confirmCardPayment without the payment method
How to avoid: If the PaymentIntent already has a payment method attached and confirmed, just pass the client_secret. If not, include the payment_method option with the Element or pm_ ID.
Why it's a problem: Not testing the 3DS failure path
How to avoid: Use card 4000008400001629 to test failed 3DS authentication. Your app must handle this gracefully and let the customer try again.
Why it's a problem: Relying only on the frontend result instead of webhooks
How to avoid: The customer might close the browser after 3DS. Use payment_intent.succeeded webhook as the source of truth for payment confirmation.
Best practices
- Always use stripe.confirmCardPayment() on the frontend to handle 3DS automatically
- Test all three scenarios: 3DS success (4000000000003055), 3DS user-choice (4000000000003220), and 3DS failure (4000008400001629)
- Set up webhooks for payment_intent.succeeded and payment_intent.payment_failed as the source of truth
- Use express.raw() for the webhook endpoint to preserve the raw body needed for signature verification
- Show a loading state while the 3DS authentication popup is open
- Provide a clear error message and retry option when 3DS fails
- Test on mobile devices — 3DS popups may behave differently on mobile browsers
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a full-stack Node.js/Express + frontend JavaScript flow for Stripe 3D Secure payments. The server creates a PaymentIntent, the frontend uses stripe.confirmCardPayment() to handle 3DS, and a webhook endpoint confirms the result using stripe.webhooks.constructEvent with raw body.
Add 3D Secure support to my Stripe payment flow. Handle the requires_action status on the frontend with stripe.confirmCardPayment(), show appropriate loading and error states, and set up a webhook to confirm the payment outcome after authentication.
Frequently asked questions
What does the 3DS test popup look like?
In Stripe's test mode, the 3DS popup shows a simple page with 'Complete authentication' and 'Fail authentication' buttons. In production, customers see their bank's actual authentication page.
Can I skip 3DS for returning customers?
Stripe Radar can request exemptions for low-risk transactions or returning customers. However, the issuing bank has final authority on whether to enforce 3DS.
Does 3DS work with Stripe Checkout?
Yes. Stripe Checkout handles 3DS automatically. No additional code is needed — Stripe manages the authentication flow within the hosted checkout page.
What happens if the customer closes the 3DS popup?
The PaymentIntent stays in requires_action status. The customer can return and retry. The PaymentIntent expires after 24 hours if not completed.
Is 3DS required for all countries?
3DS is required in the European Economic Area (EEA) and UK under SCA regulations. Other regions may not require it, but issuers can still request it.
What if I need help implementing 3D Secure across a complex payment system?
For multi-currency, multi-region payment systems with varying 3DS requirements and exemption strategies, the RapidDev team can help architect and implement a compliant solution.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation