Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 027 / 100

3D Secure and SCA: a practical Payments & Stripe guide

Strong customer authentication explained.

Expona Admin7 min read

Understanding 3D Secure and SCA requirements

Regulatory requirements around payment security have tightened significantly over the past five years, particularly in Europe and Asia-Pacific. The introduction of Strong Customer Authentication (SCA) under PSD2 regulation means that many payments now require explicit customer verification beyond a simple card number and CVC. This article covers 3D Secure and SCA: a practical Payments & Stripe guide, walking through how to implement these requirements using Stripe's modern payment APIs, handle authentication challenges in your UI, and manage off-session transactions correctly.

What SCA and 3D Secure actually mean

Strong Customer Authentication is a regulatory mandate requiring two independent verification factors when a customer initiates a payment. The most common implementation in practice is 3D Secure (3DS), which has been around in various forms since the early 2000s but was modernized significantly with 3D Secure 2.0.

When a payment requires SCA, the payment doesn't complete immediately. Instead, Stripe returns a status of requires_action, instructing your client to redirect the customer to an authentication flow—typically a bank's app or a modal challenge. Once the customer completes that challenge, the payment either succeeds or fails based on their bank's decision.

The key distinction: SCA is the regulation, 3D Secure is one technology implementing it. Stripe handles the routing automatically—if a card issuer supports 3DS 2.0, Stripe uses it; otherwise, it falls back to legacy 3DS 1.0 or other authentication methods.

Required for many European payments

Under PSD2 in Europe, SCA is mandated for most consumer-to-merchant transactions. This includes:

  • One-time card payments above €30 (though exemptions apply below this threshold)
  • Payments made from different devices or locations than usual
  • Subscription renewals and recurring charges
  • Payments where the cardholder has not transacted with the merchant before

Outside Europe, similar regulations exist—California's CCPA has payment components, and schemes like Visa and Mastercard enforce SCA where they can. As of 2024, the safest assumption is that any card payment might require SCA, so your implementation should always handle it gracefully.

A real-world scenario: you're running an e-commerce platform serving EU customers. A German customer buys a product for €50. Stripe evaluates the payment and decides it requires SCA. If your code doesn't handle the requires_action status, the payment appears to fail silently, and you've lost a customer. With proper handling, you show a challenge modal, the customer authenticates with their bank's app, and the payment succeeds.

Payment Element handles challenges automatically

Stripe's Payment Element component (available in the Stripe Web SDK and Stripe SDKs for other platforms) abstracts much of the SCA complexity away. When you submit a payment intent through Payment Element, it automatically detects when authentication is required and presents the challenge UI to the customer.

Here's a minimal flow:

typescript
import { loadStripe } from '@stripe/js';
import { Elements, Payment Element, useElements, useStripe } from '@stripe/react-stripe-js';

const stripePromise = loadStripe('pk_live_...');

function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!stripe || !elements) return;

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: 'https://example.com/checkout/success',
      },
    });

    if (error) {
      console.error('Payment failed:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit">Pay</button>
    </form>
  );
}

When confirmPayment is called, Payment Element checks the payment intent status. If it's requires_action, it automatically shows the challenge UI (typically an iframe embedding the bank's authentication page). Once the customer completes authentication, the payment intent's status updates server-side, and Payment Element handles the flow.

The critical point: Payment Element communicates with Stripe's backend continuously. You don't need to manually poll or handle iframe messages—the library manages all of that complexity.

However, if you're using the lower-level confirmCardPayment or building a custom UI, you have more control but also more responsibility for handling the challenge flow.

Requires_action status needs client handling

When Payment Element is not in use—or when you're debugging—understanding the requires_action status is essential.

On the server, after creating a payment intent, you receive:

typescript
const paymentIntent = await stripe.paymentIntents.create({
  amount: 5000, // $50.00
  currency: 'usd',
  payment_method: 'pm_card_visa',
  confirm: true,
});

console.log(paymentIntent.status); // might be "requires_action"
console.log(paymentIntent.client_secret); // needed on client

If the status is requires_action, you must pass the client_secret to your client and tell it that authentication is required. The client then uses this secret to complete the challenge:

typescript
// Client-side
const { paymentIntent, error } = await stripe.handleCardAction(clientSecret);

if (error) {
  console.error('Authentication failed:', error);
} else if (paymentIntent.status === 'succeeded') {
  console.log('Payment authenticated and completed');
} else if (paymentIntent.status === 'requires_action') {
  // Customer needs to complete additional steps
  console.log('Still requires action');
}

The flow is: server sends client_secret → client calls handleCardAction → customer completes challenge → server receives webhook confirming success or failure.

A common pitfall: assuming that once you call confirmPayment, the payment is done. In reality, your webhook listener is the source of truth. Always verify the payment intent status via webhook before fulfilling the order.

Off-session payments need saved mandates

Off-session payments are charges that occur without the customer actively participating—renewals, subscriptions, invoices sent later. These require explicit permission and are treated more strictly by SCA rules.

In Stripe terminology, you need to save the customer's payment method with setup_future_usage set to off_session:

typescript
const paymentIntent = await stripe.paymentIntents.create({
  amount: 5000,
  currency: 'usd',
  customer: 'cus_...',
  payment_method: 'pm_...',
  off_session: true,
  confirm: true,
  setup_future_usage: 'off_session',
});

When creating a subscription, set it similarly:

typescript
const subscription = await stripe.subscriptions.create({
  customer: 'cus_...',
  items: [{ price: 'price_...' }],
  payment_settings: {
    save_default_payment_method: 'on_subscription',
    default_mandate_payment_method: 'pm_...',
  },
});

The mandate tells the customer's bank: "This merchant has permission to charge this card later without my direct involvement." Without the mandate, many banks will decline off-session payments.

Additionally, when you charge a customer's saved payment method later, include the off_session flag:

typescript
const paymentIntent = await stripe.paymentIntents.create({
  amount: 5000,
  currency: 'usd',
  customer: 'cus_...',
  payment_method: 'pm_...',
  off_session: true,
  confirm: true,
});

This tells Stripe (and the card network) that the customer is not directly authorizing this charge, which affects how SCA is applied. Many off-session payments are exempt from SCA requirements, but the networks make the final call.

Real-world scenario: you're building a SaaS platform with monthly subscriptions. When a customer signs up, you collect their payment method on-session (with full SCA handling). You create a subscription, ensuring save_default_payment_method is set. Each month when you charge them, you mark the payment intent as off_session. If their card was saved with proper consent, most renewals will succeed without authentication.

Common mistakes

  • Ignoring requires_action status: Treating requires_action as an error instead of a valid intermediate state. The payment isn't declined; it's waiting for the customer to authenticate.
  • Not passing client_secret to the client: If you create a payment intent on the server but don't send the secret to the frontend, the client can't complete the authentication flow.
  • Forgetting webhook verification: Assuming a payment succeeded because the client sees success. Always verify via webhook before fulfilling the order.
  • Missing setup_future_usage for subscriptions: Creating subscriptions without properly marking them for off-session use, causing unexpected SCA challenges or declines later.
  • Testing with the wrong test cards: Using a standard test card (like 4242 4242 4242 4242) in production and forgetting that real cards behave differently. In staging, use cards that explicitly trigger 3D Secure, like 4000 0000 0000 3220.
  • Not handling network errors gracefully: If the customer's connection drops during authentication, re-attempting the entire payment flow instead of recovering from the challenge.

Key takeaways

  • SCA is regulatory reality in Europe and growing elsewhere; assume all payments might require it.
  • Payment Element simplifies SCA by handling challenges automatically—use it unless you have specific UI constraints.
  • When status is requires_action, pass the client_secret to the client and let them complete the authentication challenge.
  • Off-session payments (subscriptions, renewals) require explicit setup_future_usage and off_session flags to comply with regulations and reduce declines.
  • Webhooks are the source of truth—never assume a payment succeeded based solely on client-side feedback.
  • Test thoroughly with 3D Secure-triggering cards (4000 0000 0000 3220) in your staging environment before going live.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles