Skip to main content
RapidDev - Software Development Agency
stripe-guide

How to implement 3D Secure with Stripe Elements

3D Secure (3DS) adds an authentication step to card payments, showing customers a bank verification popup. Stripe Elements triggers 3DS automatically when required by the card issuer. This guide covers integrating 3DS with Stripe Elements, handling the authentication flow with confirmCardPayment, testing with specific 3DS test cards, and managing edge cases like failed authentication.

What you'll learn

  • How 3D Secure works with Stripe Elements
  • How to use confirmCardPayment to handle 3DS authentication
  • How to test all 3DS scenarios with Stripe test cards
  • How to handle authentication failures and edge cases
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced7 min read20 minutesStripe API v2024-12+, Node.js 18+, Stripe.js v3+March 2026RapidDev Engineering Team
TL;DR

3D Secure (3DS) adds an authentication step to card payments, showing customers a bank verification popup. Stripe Elements triggers 3DS automatically when required by the card issuer. This guide covers integrating 3DS with Stripe Elements, handling the authentication flow with confirmCardPayment, testing with specific 3DS test cards, and managing edge cases like failed authentication.

Implementing 3D Secure Authentication with Stripe Elements

3D Secure (3DS) is a security protocol that adds an authentication layer to online card payments. When triggered, customers see a popup from their bank asking them to verify their identity. Stripe Elements handles the entire 3DS flow through the confirmCardPayment method — you just need to handle the response correctly. Modern 3DS2 provides a frictionless experience for low-risk transactions while requiring explicit authentication for higher-risk ones.

Prerequisites

  • Stripe account (test mode)
  • Node.js 18 or later installed
  • Stripe Node.js SDK: npm install stripe
  • Stripe.js v3 loaded on your frontend
  • Basic HTML page with a card form

Step-by-step guide

1

Set up Stripe Elements with a card form

Create an HTML form with a Stripe card Element. The Element securely collects card details without them touching your server.

typescript
1<!-- Frontend HTML -->
2<form id="payment-form">
3 <div id="card-element"></div>
4 <div id="card-errors" role="alert"></div>
5 <button type="submit">Pay</button>
6</form>
7
8<script src="https://js.stripe.com/v3/"></script>
9<script>
10const stripe = Stripe('pk_test_yourPublishableKey');
11const elements = stripe.elements();
12const cardElement = elements.create('card', {
13 style: {
14 base: {
15 fontSize: '16px',
16 color: '#32325d',
17 },
18 },
19});
20cardElement.mount('#card-element');
21
22// Handle validation errors
23cardElement.on('change', (event) => {
24 const errorEl = document.getElementById('card-errors');
25 errorEl.textContent = event.error ? event.error.message : '';
26});
27</script>

Expected result: A secure card input field renders on the page, ready to collect card details.

2

Create a PaymentIntent on the server

Your server creates a PaymentIntent and returns the client_secret to the frontend. Stripe uses this to manage the 3DS flow.

typescript
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
2const express = require('express');
3const app = express();
4app.use(express.json());
5
6app.post('/api/create-payment-intent', async (req, res) => {
7 try {
8 const paymentIntent = await stripe.paymentIntents.create({
9 amount: req.body.amount, // cents
10 currency: 'eur',
11 automatic_payment_methods: { enabled: true },
12 });
13 res.json({ clientSecret: paymentIntent.client_secret });
14 } catch (err) {
15 res.status(400).json({ error: err.message });
16 }
17});

Expected result: The server returns a client_secret that the frontend uses to complete the payment.

3

Confirm the payment with 3DS handling

Use stripe.confirmCardPayment on the frontend. If 3DS is required, Stripe automatically shows the authentication modal.

typescript
1// Frontend JavaScript
2const form = document.getElementById('payment-form');
3
4form.addEventListener('submit', async (event) => {
5 event.preventDefault();
6
7 // Step 1: Get client secret from server
8 const response = await fetch('/api/create-payment-intent', {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify({ amount: 5000 }), // $50.00
12 });
13 const { clientSecret } = await response.json();
14
15 // Step 2: Confirm payment — 3DS popup appears automatically if needed
16 const { paymentIntent, error } = await stripe.confirmCardPayment(
17 clientSecret,
18 {
19 payment_method: {
20 card: cardElement,
21 billing_details: {
22 name: 'Customer Name',
23 },
24 },
25 }
26 );
27
28 if (error) {
29 // Authentication failed or card was declined
30 document.getElementById('card-errors').textContent = error.message;
31 } else if (paymentIntent.status === 'succeeded') {
32 // Payment successful, no 3DS or 3DS passed
33 alert('Payment successful!');
34 }
35});

Expected result: For cards requiring 3DS, a bank authentication popup appears. After successful auth, the payment completes.

4

Test different 3DS scenarios

Stripe provides test cards for each 3DS outcome. Use these to verify your implementation handles all cases.

typescript
1// 3DS Test Cards (any future expiry, any CVC):
2//
3// ALWAYS requires authentication:
4// 4000002500003155 — Requires auth, succeeds when completed
5// 4000002760003184 — Requires auth, succeeds when completed
6//
7// Authentication FAILS:
8// 4000008260003178 — Requires auth, fails authentication
9//
10// NEVER requires authentication:
11// 4242424242424242 — Succeeds without 3DS
12//
13// 3DS2 frictionless (SCA applied but no popup):
14// 4000003800000446 — Approved with frictionless 3DS2
15//
16// Insufficient funds after 3DS:
17// 4000000000009995 — Auth succeeds, but charge is declined
18
19console.log('Test each card to verify:');
20console.log('1. 3DS popup appears for required cards');
21console.log('2. Payment succeeds after authentication');
22console.log('3. Failed auth shows a clear error');
23console.log('4. Non-3DS cards skip the popup');

Expected result: You have tested all 3DS scenarios and confirmed your payment flow handles each correctly.

5

Handle 3DS edge cases

Handle scenarios where the customer closes the authentication popup, the bank is unavailable, or the payment requires a redirect.

typescript
1async function handlePaymentWithEdgeCases(clientSecret) {
2 try {
3 const { paymentIntent, error } = await stripe.confirmCardPayment(
4 clientSecret,
5 { payment_method: { card: cardElement } }
6 );
7
8 if (error) {
9 switch (error.code) {
10 case 'payment_intent_authentication_failure':
11 showError('Authentication failed. Please try again or use a different card.');
12 break;
13 case 'card_declined':
14 showError('Card was declined. Please use a different card.');
15 break;
16 case 'expired_card':
17 showError('Card has expired. Please use a different card.');
18 break;
19 default:
20 showError(error.message);
21 }
22 return;
23 }
24
25 switch (paymentIntent.status) {
26 case 'succeeded':
27 showSuccess('Payment completed!');
28 break;
29 case 'processing':
30 showInfo('Payment is processing. You will be notified when it completes.');
31 break;
32 case 'requires_payment_method':
33 showError('Payment failed. Please try a different card.');
34 break;
35 }
36 } catch (err) {
37 showError('An unexpected error occurred. Please try again.');
38 }
39}

Expected result: All edge cases are handled with clear user-facing messages.

Complete working example

three-d-secure-server.js
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
2const express = require('express');
3const app = express();
4
5app.use(express.json());
6app.use(express.static('public'));
7
8app.get('/api/config', (req, res) => {
9 res.json({ publishableKey: process.env.STRIPE_PUBLISHABLE_KEY });
10});
11
12app.post('/api/create-payment-intent', async (req, res) => {
13 try {
14 const { amount, currency } = req.body;
15 const pi = await stripe.paymentIntents.create({
16 amount: amount || 5000,
17 currency: currency || 'eur',
18 automatic_payment_methods: { enabled: true },
19 });
20 res.json({ clientSecret: pi.client_secret });
21 } catch (err) {
22 res.status(400).json({ error: err.message });
23 }
24});
25
26app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
27 const sig = req.headers['stripe-signature'];
28 let event;
29 try {
30 event = stripe.webhooks.constructEvent(
31 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
32 );
33 } catch (err) {
34 return res.status(400).send(`Webhook Error: ${err.message}`);
35 }
36
37 switch (event.type) {
38 case 'payment_intent.succeeded':
39 console.log('Payment succeeded:', event.data.object.id);
40 // Fulfill the order
41 break;
42 case 'payment_intent.payment_failed':
43 const pi = event.data.object;
44 const error = pi.last_payment_error;
45 console.log(`Payment failed: ${pi.id}`);
46 console.log(`Reason: ${error?.message || 'unknown'}`);
47 break;
48 }
49 res.json({ received: true });
50});
51
52app.listen(3000, () => console.log('3DS server on port 3000'));

Common mistakes when implementing 3D Secure with Stripe Elements

Why it's a problem: Not testing with 3DS-required test cards

How to avoid: The standard 4242 card does not trigger 3DS. Use 4000002500003155 to test the full authentication flow.

Why it's a problem: Treating the confirmCardPayment response as the sole source of truth

How to avoid: Network issues can prevent the client from receiving the response. Always use payment_intent.succeeded webhook as the definitive confirmation.

Why it's a problem: Not handling the case where the customer closes the 3DS popup

How to avoid: If the customer closes the popup, confirmCardPayment returns an error with code payment_intent_authentication_failure. Show a retry option.

Why it's a problem: Using the old Charges API which does not support 3DS

How to avoid: Always use PaymentIntents for 3DS support. The Charges API cannot trigger or handle 3D Secure authentication.

Best practices

  • Use confirmCardPayment which handles the entire 3DS flow including the popup and confirmation
  • Test with all 3DS test cards: success, failure, frictionless, and decline-after-auth scenarios
  • Use webhooks (payment_intent.succeeded) as the authoritative payment confirmation
  • Provide clear error messages when authentication fails so customers know to try again
  • Add billing_details to confirmCardPayment to improve 3DS2 frictionless approval rates
  • Monitor your 3DS authentication rates in the Stripe Dashboard to identify issues
  • For complex 3DS implementations across multiple payment flows, RapidDev can help ensure full coverage

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

How do I implement 3D Secure with Stripe Elements? I need a payment form that handles 3DS authentication popups automatically and processes the payment after verification. Show me frontend and backend code.

Stripe Prompt

Build a complete 3D Secure payment flow using Stripe Elements. I need a server that creates PaymentIntents, a frontend form with confirmCardPayment handling, and proper error handling for failed authentication. Include test cards for all scenarios.

Frequently asked questions

Does 3D Secure always show a popup?

No. 3DS2 supports 'frictionless' authentication where the bank verifies the customer silently based on risk signals. The popup only appears when active authentication is needed.

Does 3D Secure increase conversion rates or decrease them?

3DS can reduce conversion by 2-10% due to the extra step. However, 3DS2 frictionless flow minimizes this. 3DS also shifts chargeback liability to the card issuer, reducing fraud costs.

Can I force 3D Secure on all payments?

Yes. Set request_three_d_secure to 'any' in payment_method_options.card. However, this reduces conversion and is not recommended unless your fraud rate requires it.

What is the difference between 3DS1 and 3DS2?

3DS1 always shows a full-page redirect for authentication. 3DS2 supports frictionless authentication and in-context popups. Stripe automatically uses 3DS2 when supported by the card issuer.

Does 3D Secure work with saved cards?

Yes. When charging a saved card off-session, if the issuer requires authentication, the PaymentIntent will have status requires_action. You need to bring the customer back on-session to complete it.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.