/stripe-guides

How to handle SCA (Strong Customer Authentication) in Stripe?

Learn how to handle SCA (Strong Customer Authentication) in Stripe with step-by-step guidance on setup, Payment Intents, 3D Secure, off-session payments, and subscriptions.

Matt Graham, CEO of Rapid Developers

Book a call with an Expert

Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.

Book a free consultation

How to handle SCA (Strong Customer Authentication) in Stripe?

How to Handle SCA (Strong Customer Authentication) in Stripe

 

Step 1: Understanding SCA Requirements

 

Strong Customer Authentication (SCA) is a European regulatory requirement to reduce fraud and make online payments more secure. It requires authentication using at least two of the following:

  • Something the customer knows (password, PIN)
  • Something the customer has (phone, hardware token)
  • Something the customer is (fingerprint, face recognition)

In Stripe, this typically means implementing 3D Secure authentication for card payments.

 

Step 2: Setting Up Your Stripe Account

 

Before implementing SCA, ensure your Stripe account is properly configured:

  • Create a Stripe account if you don't have one
  • Install the Stripe library in your project

For Node.js:

npm install stripe

For PHP:

composer require stripe/stripe-php

 

Step 3: Implementing the Payment Intent API

 

The Payment Intent API is central to handling SCA. It manages the payment flow and handles authentication when needed:

// Node.js
const stripe = require('stripe')('sk_test_your\_key');

async function createPaymentIntent(amount, currency) {
  const paymentIntent = await stripe.paymentIntents.create({
    amount: amount,
    currency: currency,
    // Automatically handles SCA requirements when needed
    automatic_payment_methods: { enabled: true }
  });
  
  return paymentIntent;
}

 

Step 4: Client-Side Implementation with Stripe Elements

 

Implement Stripe Elements on your frontend to securely collect payment details:

<!-- HTML structure -->
<form id="payment-form">
  <div id="payment-element"></div>
  <button id="submit">Pay now</button>
  <div id="error-message"></div>
</form>

<script src="https://js.stripe.com/v3/"></script>
<script>
  const stripe = Stripe('pk_test_your\_key');
  
  // Fetch the PaymentIntent client secret from your server
  fetch('/create-payment-intent', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: 1000, currency: 'eur' })
  })
  .then(response => response.json())
  .then(data => {
    const { clientSecret } = data;
    
    const elements = stripe.elements({ clientSecret });
    const paymentElement = elements.create('payment');
    paymentElement.mount('#payment-element');
    
    // Handle form submission
    const form = document.getElementById('payment-form');
    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      
      const { error } = await stripe.confirmPayment({
        elements,
        confirmParams: {
          return\_url: 'https://example.com/payment-complete',
        },
      });
      
      if (error) {
        const messageElement = document.getElementById('error-message');
        messageElement.textContent = error.message;
      }
    });
  });
</script>

 

Step 5: Creating a Server Endpoint for PaymentIntent

 

Create a server endpoint to generate a PaymentIntent and return the client secret:

// Node.js with Express
const express = require('express');
const app = express();
const stripe = require('stripe')('sk_test_your\_key');

app.use(express.json());

app.post('/create-payment-intent', async (req, res) => {
  try {
    const { amount, currency } = req.body;
    
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      automatic_payment_methods: { enabled: true },
    });
    
    res.json({ clientSecret: paymentIntent.client\_secret });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

 

Step 6: Handling the Return from 3D Secure Authentication

 

When SCA is required, the customer will be redirected to complete 3D Secure authentication. Set up a page to handle the return:

<!-- payment-complete.html -->
<div id="status-container">Processing payment...</div>

<script src="https://js.stripe.com/v3/"></script>
<script>
  const stripe = Stripe('pk_test_your\_key');
  
  // Extract the client secret from the URL query parameters
  const clientSecret = new URLSearchParams(window.location.search).get('payment_intent_client\_secret');
  
  if (clientSecret) {
    stripe.retrievePaymentIntent(clientSecret)
      .then(({ paymentIntent }) => {
        const statusContainer = document.getElementById('status-container');
        
        // Handle payment status
        switch (paymentIntent.status) {
          case 'succeeded':
            statusContainer.textContent = 'Payment succeeded!';
            // Handle successful payment
            break;
          case 'processing':
            statusContainer.textContent = 'Payment processing.';
            // Handle processing payment
            break;
          case 'requires_payment_method':
            statusContainer.textContent = 'Payment failed. Please try another payment method.';
            // Handle failed payment
            break;
          default:
            statusContainer.textContent = `Unexpected status: ${paymentIntent.status}`;
        }
      });
  }
</script>

 

Step 7: Implementing Off-Session Payments with Setup Intents

 

For recurring payments or future off-session charges that might require SCA, use Setup Intents to save payment methods:

// Server-side: Create a Setup Intent
const setupIntent = await stripe.setupIntents.create({
  payment_method_types: ['card'],
  customer: 'cus_customer_id', // Attach to a customer
});

// Return client\_secret to the frontend
res.json({ clientSecret: setupIntent.client\_secret });

Client-side implementation:

<script>
  const stripe = Stripe('pk_test_your\_key');
  const elements = stripe.elements();
  const cardElement = elements.create('card');
  cardElement.mount('#card-element');
  
  const form = document.getElementById('setup-form');
  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    
    const { setupIntent, error } = await stripe.confirmCardSetup(
      clientSecret,
      {
        payment\_method: {
          card: cardElement,
          billing\_details: { name: 'Customer Name' },
        },
      }
    );
    
    if (error) {
      // Handle error
      console.error(error);
    } else {
      // The card has been successfully set up
      console.log('Success! Payment method ID:', setupIntent.payment\_method);
      
      // Send the payment method ID to your server to save for future payments
      savePaymentMethod(setupIntent.payment\_method);
    }
  });
  
  function savePaymentMethod(paymentMethodId) {
    // Send to your server
    fetch('/save-payment-method', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ paymentMethodId }),
    });
  }
</script>

 

Step 8: Making Off-Session Charges with SCA Handling

 

When charging a saved payment method off-session, handle potential SCA requirements:

// Server-side code
async function chargeCustomerOffSession(customerId, paymentMethodId, amount) {
  try {
    // Create a PaymentIntent
    const paymentIntent = await stripe.paymentIntents.create({
      amount: amount,
      currency: 'eur',
      customer: customerId,
      payment\_method: paymentMethodId,
      off\_session: true,
      confirm: true,
    });
    
    // Payment succeeded without SCA
    return { success: true, paymentIntent };
  } catch (error) {
    // Error code for authentication required
    if (error.code === 'authentication\_required') {
      // Create a new PaymentIntent for on-session authentication
      const paymentIntent = await stripe.paymentIntents.create({
        amount: amount,
        currency: 'eur',
        customer: customerId,
        payment\_method: paymentMethodId,
        confirm: false, // Don't confirm yet
        setup_future_usage: 'off\_session',
      });
      
      // Return the client secret to complete authentication
      return {
        success: false,
        requires\_action: true,
        client_secret: paymentIntent.client_secret,
      };
    }
    
    // Handle other errors
    return { success: false, error: error.message };
  }
}

 

Step 9: Handling SCA Exceptions and Exemptions

 

Some transactions may be exempt from SCA. Stripe automatically applies for exemptions when possible:

// Request an exemption when creating a PaymentIntent
const paymentIntent = await stripe.paymentIntents.create({
  amount: 1000,
  currency: 'eur',
  payment_method_types: ['card'],
  // Apply for low-value exemption if eligible (transaction < €30)
  setup_future_usage: 'off\_session',
});

 

Step 10: Testing SCA Implementation

 

Use Stripe's test cards to verify your SCA implementation:

  • Card requiring authentication: 4000 0025 0000 3155
  • Card that will always succeed: 4242 4242 4242 4242
  • Card that will be declined: 4000 0000 0000 9995

Test code:

// Test with a card requiring authentication
const testSCA = async () => {
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: 1999,
      currency: 'eur',
      payment_method_types: ['card'],
      payment_method: 'pm_card\_threeDSecure2Required', // Test SCA card
      confirm: true,
    });
    
    console.log('Authentication status:', paymentIntent.status);
    // Should return 'requires\_action'
    
  } catch (error) {
    console.error('Error:', error);
  }
};

 

Step 11: Monitoring and Handling Payment Status

 

Set up webhook endpoints to monitor payment status and handle events:

// Node.js with Express
const endpointSecret = 'whsec_your_webhook\_secret';

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  let event;
  
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  
  // Handle the event
  switch (event.type) {
    case 'payment\_intent.succeeded':
      const paymentIntent = event.data.object;
      console.log('PaymentIntent was successful!', paymentIntent.id);
      // Update your database, fulfill the order, etc.
      break;
    case 'payment_intent.payment_failed':
      const failedPayment = event.data.object;
      console.log('Payment failed:', failedPayment.id, failedPayment.last_payment_error?.message);
      // Notify the customer, retry payment, etc.
      break;
    case 'payment_intent.requires_action':
      const requiresAction = event.data.object;
      console.log('Payment requires authentication:', requiresAction.id);
      // Notify the customer to complete authentication
      break;
    default:
      console.log(`Unhandled event type ${event.type}`);
  }
  
  res.send();
});

 

Step 12: Implementing a Complete Payment Flow with SCA

 

Here's a complete example of a payment flow handling SCA:

// Server-side (Node.js with Express)
const express = require('express');
const app = express();
const stripe = require('stripe')('sk_test_your\_key');

app.use(express.static('public'));
app.use(express.json());

// Create PaymentIntent
app.post('/create-payment', async (req, res) => {
  try {
    const { amount, currency, customer\_email } = req.body;
    
    // Create or retrieve customer
    let customer;
    const customers = await stripe.customers.list({
      email: customer\_email,
      limit: 1,
    });
    
    if (customers.data.length > 0) {
      customer = customers.data[0];
    } else {
      customer = await stripe.customers.create({
        email: customer\_email,
      });
    }
    
    // Create PaymentIntent
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      customer: customer.id,
      setup_future_usage: 'off\_session', // Save for future use
      automatic_payment_methods: { enabled: true }
    });
    
    res.json({ 
      clientSecret: paymentIntent.client\_secret,
      customerId: customer.id
    });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Save payment method for future use
app.post('/save-payment-method', async (req, res) => {
  try {
    const { customerId, paymentMethodId } = req.body;
    
    // Attach payment method to customer
    await stripe.paymentMethods.attach(paymentMethodId, {
      customer: customerId,
    });
    
    // Set as default payment method
    await stripe.customers.update(customerId, {
      invoice\_settings: {
        default_payment_method: paymentMethodId,
      },
    });
    
    res.json({ success: true });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Make off-session payment
app.post('/charge-customer', async (req, res) => {
  try {
    const { customerId, amount, currency } = req.body;
    
    // Get customer's default payment method
    const customer = await stripe.customers.retrieve(customerId);
    const paymentMethodId = customer.invoice_settings.default_payment\_method;
    
    if (!paymentMethodId) {
      return res.status(400).json({ error: 'No payment method found' });
    }
    
    try {
      // Attempt to charge without authentication
      const paymentIntent = await stripe.paymentIntents.create({
        amount,
        currency,
        customer: customerId,
        payment\_method: paymentMethodId,
        off\_session: true,
        confirm: true,
      });
      
      res.json({ success: true, paymentIntent });
    } catch (error) {
      if (error.code === 'authentication\_required') {
        // SCA required, create a new PaymentIntent for on-session authentication
        const paymentIntent = await stripe.paymentIntents.create({
          amount,
          currency,
          customer: customerId,
          payment\_method: paymentMethodId,
          setup_future_usage: 'off\_session',
        });
        
        res.json({
          success: false,
          requires\_action: true,
          client_secret: paymentIntent.client_secret,
        });
      } else {
        throw error;
      }
    }
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

Client-side implementation:

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SCA Payment Example</title>
  <script src="https://js.stripe.com/v3/"></script>
  <style>
    .form-group { margin-bottom: 20px; }
    #payment-element { margin: 20px 0; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <h1>Checkout with SCA</h1>
  
  <div class="form-group">
    <label for="email">Email:</label>
    <input type="email" id="email" required>
  </div>
  
  <div class="form-group">
    <label for="amount">Amount (in cents):</label>
    <input type="number" id="amount" value="1999" required>
  </div>
  
  <form id="payment-form">
    <div id="payment-element"></div>
    <button type="submit" id="submit-button">Pay now</button>
    <div id="error-message"></div>
  </form>
  
  <div id="success-message" class="hidden">
    <h2>Payment successful!</h2>
    <p>Your payment was processed successfully.</p>
    
    <div class="form-group">
      <button id="pay-again-button">Make another payment</button>
    </div>
  </div>
  
  <script>
    const stripe = Stripe('pk_test_your\_key');
    let elements;
    let customerId;
    
    // Initialize the payment form
    async function initializePayment() {
      const emailInput = document.getElementById('email');
      const amountInput = document.getElementById('amount');
      
      // Validate inputs
      if (!emailInput.value || !amountInput.value) {
        alert('Please enter email and amount');
        return;
      }
      
      try {
        // Create PaymentIntent on the server
        const response = await fetch('/create-payment', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            amount: parseInt(amountInput.value),
            currency: 'eur',
            customer\_email: emailInput.value
          })
        });
        
        const data = await response.json();
        
        if (data.error) {
          throw new Error(data.error);
        }
        
        const { clientSecret, customerId: custId } = data;
        customerId = custId;
        
        // Initialize Stripe Elements
        elements = stripe.elements({ clientSecret });
        const paymentElement = elements.create('payment');
        paymentElement.mount('#payment-element');
        
        // Handle form submission
        const form = document.getElementById('payment-form');
        form.addEventListener('submit', handleSubmit);
        
        document.getElementById('payment-form').classList.remove('hidden');
      } catch (error) {
        const errorMsg = document.getElementById('error-message');
        errorMsg.textContent = error.message;
      }
    }
    
    // Handle the payment form submission
    async function handleSubmit(e) {
      e.preventDefault();
      
      const submitButton = document.getElementById('submit-button');
      submitButton.disabled = true;
      
      // Confirm the payment
      const { error } = await stripe.confirmPayment({
        elements,
        confirmParams: {
          return\_url: window.location.origin + '/payment-complete.html',
        },
        redirect: 'if\_required'
      });
      
      if (error) {
        // Show error message
        const errorMsg = document.getElementById('error-message');
        errorMsg.textContent = error.message;
        submitButton.disabled = false;
      } else {
        // Payment succeeded without 3D Secure redirect
        handlePaymentSuccess();
      }
    }
    
    // Handle successful payment
    function handlePaymentSuccess() {
      document.getElementById('payment-form').classList.add('hidden');
      document.getElementById('success-message').classList.remove('hidden');
      
      // Enable "Pay Again" button
      document.getElementById('pay-again-button').addEventListener('click', () => {
        document.getElementById('success-message').classList.add('hidden');
        document.getElementById('payment-form').classList.remove('hidden');
        initializePayment();
      });
    }
    
    // Initialize page
    document.addEventListener('DOMContentLoaded', () => {
      const submitButton = document.getElementById('submit-button');
      submitButton.addEventListener('click', () => {
        if (!elements) {
          initializePayment();
        }
      });
      
      // Check for redirect from 3D Secure
      const urlParams = new URLSearchParams(window.location.search);
      const paymentIntentClientSecret = urlParams.get('payment_intent_client\_secret');
      
      if (paymentIntentClientSecret) {
        stripe.retrievePaymentIntent(paymentIntentClientSecret)
          .then(({ paymentIntent }) => {
            if (paymentIntent.status === 'succeeded') {
              handlePaymentSuccess();
            }
          });
      }
    });
  </script>
</body>
</html>

 

Step 13: Implementing SCA for Subscriptions

 

For subscription payments that require SCA:

// Server-side code
app.post('/create-subscription', async (req, res) => {
  const { customerId, priceId, paymentMethodId } = req.body;
  
  try {
    // Set the default payment method on the customer
    await stripe.customers.update(customerId, {
      invoice\_settings: {
        default_payment_method: paymentMethodId,
      },
    });
    
    // Create the subscription
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }],
      expand: ['latest_invoice.payment_intent'],
    });
    
    const status = subscription.latest_invoice.payment_intent.status;
    const clientSecret = subscription.latest_invoice.payment_intent.client\_secret;
    
    res.json({
      subscriptionId: subscription.id,
      status,
      clientSecret: status === 'requires\_action' ? clientSecret : null,
    });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Client-side subscription handling:

<script>
  async function createSubscription(priceId) {
    try {
      // Create subscription on server
      const response = await fetch('/create-subscription', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          customerId: customerId,
          priceId: priceId,
          paymentMethodId: paymentMethodId
        })
      });
      
      const subscription = await response.json();
      
      // If subscription needs SCA
      if (subscription.status === 'requires\_action') {
        const { error, paymentIntent } = await stripe.confirmCardPayment(
          subscription.clientSecret
        );
        
        if (error) {
          // Handle error
          throw new Error(error.message);
        } else {
          // Subscription active
          alert('Subscription activated successfully!');
        }
      } else {
        // Subscription active
        alert('Subscription activated successfully!');
      }
    } catch (error) {
      alert('Error: ' + error.message);
    }
  }
</script>

Want to explore opportunities to work with us?

Connect with our team to unlock the full potential of no-code solutions with a no-commitment consultation!

Book a Free Consultation

Client trust and success are our top priorities

When it comes to serving you, we sweat the little things. That’s why our work makes a big impact.

Rapid Dev was an exceptional project management organization and the best development collaborators I've had the pleasure of working with. They do complex work on extremely fast timelines and effectively manage the testing and pre-launch process to deliver the best possible product. I'm extremely impressed with their execution ability.

CPO, Praction - Arkady Sokolov

May 2, 2023

Working with Matt was comparable to having another co-founder on the team, but without the commitment or cost. He has a strategic mindset and willing to change the scope of the project in real time based on the needs of the client. A true strategic thought partner!

Co-Founder, Arc - Donald Muir

Dec 27, 2022

Rapid Dev are 10/10, excellent communicators - the best I've ever encountered in the tech dev space. They always go the extra mile, they genuinely care, they respond quickly, they're flexible, adaptable and their enthusiasm is amazing.

Co-CEO, Grantify - Mat Westergreen-Thorne

Oct 15, 2022

Rapid Dev is an excellent developer for no-code and low-code solutions.
We’ve had great success since launching the platform in November 2023. In a few months, we’ve gained over 1,000 new active users. We’ve also secured several dozen bookings on the platform and seen about 70% new user month-over-month growth since the launch.

Co-Founder, Church Real Estate Marketplace - Emmanuel Brown

May 1, 2024 

Matt’s dedication to executing our vision and his commitment to the project deadline were impressive. 
This was such a specific project, and Matt really delivered. We worked with a really fast turnaround, and he always delivered. The site was a perfect prop for us!

Production Manager, Media Production Company - Samantha Fekete

Sep 23, 2022