We live in a world where we interact with a lot of online services and make payments to these services through online payment gateways. And we, as developers, have the responsibility to integrate these payment gateways in such a way that it’s secure for both the user and the party collecting the payment.
In this article we will cover how to implement 3D Secure protection on accepting payments online using Stripe.
3D Secure is a way for Stripe to authenticate a user before actually processing the payment. When a user enters his card details, he is prompted with a popup or a redirect to authenticate the payment.
It’s usually verifying identity via OTP, but it might depend upon the bank issuing the card. In some countries 3D Secure is not necessary, but in countries like India, 3D Secure is required.
You can set your radar rules in your Stripe account to require 3D Secure authentication, but it’s of no use if you don’t have the code in your payment form to make a 3D Secure popup work.
In this article we will create a simple donation web application made using NodeJS, React, and of course, Stripe. We will cover the following topics:
First, we will work on the back end. I prefer an “API first approach” which means you first create an API and then work on the rest of the front end.
We will create our back end using NodeJS, Express, and a Stripe package to fetch the payment related content.
Let’s create our back end. To do that, open the terminal/command prompt and type the following command to initiate a NodeJS project in your desired folder:
npm init -y
Running this command will generate a package.json
file in the folder.
Now open VSCode in the folder using the following command so that we can start editing:
code .
Now that VSCode is opened, you can use the integrated terminal, which will make your life easier. Just hit Ctrl + J on Windows or Command + J on Mac to open the terminal in VSCode.
Let’s install few packages that will help us with the project further. Type the following command in the terminal and we will see what these packages do:
npm install express cors stripe dotenv
These are the packages being installed:
Express
is used to easily create HTTP serversCORS
helps us get rid of cross origin errors in our client applicationsStripe
is the actual connection to Stripe. We can fetch payment details and create payments using this packageDotenv
helps us enable environment variables to store sensitive dataBefore moving further with this payment system, let’s set up the Stripe secret key in the environment variable.
All secret API keys and credentials must be stored in environment variables so that the data doesn’t get stolen if the actual code is stolen.
To get your Stripe secret key, open your Stripe dashboard and you will see a side menu similar to the picture below:
Now, click on Developers, and then API Keys. There you should see your Stripe publishable and secret key.
For now, we need the secret key. Please note that you should not share your secret key with anyone. Sharing your secret key will give others access to your Stripe account.
On the other hand, the publishable key is the one we use in the front end and it doesn’t matter if anyone gains access to it, because it’s meant to be public.
Now, copy your Stripe secret key and go to VSCode, create a new file named .env
, and paste the key in the following format:
STRIPE_SECRET_KEY=(secret key here)
The .env
file is used to store environment variables. The dotenv
package will search for this file to load the environment variables. Now that the .env
file is done, we don’t need to touch environment variables again in this tutorial.
While following the tutorial, you might need to restart the server several times. To avoid that, we can install a global package called nodemon
which will automatically restart our server whenever we save a file. You can read more about Nodemon here.
Type the following command in the terminal:
npm install -g nodemon
Use sudo
if required because Nodemon is supposed to be installed globally, so it will require root permissions.
Let’s create the file that will run our server. We can name it index.js
because it’s specified as main
by default in the package.json
file. You can change the name if you want, but we will stick with index.js
in this tutorial.
Let’s start off by creating an Express server and a simple route:
const express = require("express"); const app = express(); const PORT = process.env.PORT || 5000; const cors = require("cors"); app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.get("/", (req, res) => res.json({ status: 200, message: "API Works" })); app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
This creates a simple Express server with one home route, which simply returns a JSON saying the API works.
Here, we set the port to process.env.PORT || 5000
because if you decide to deploy this server to a service like Heroku, they will host it on their own ports which are stored in their environment variables, so we let them decide the port. If process.env.PORT
is undefined, the app is running locally and port 5000 will be used.
We use the cors
package as an Express middleware so that client applications can interact with our server properly without any cross-origin errors. You can configure the cors
package according to your needs, but for this tutorial, we will just allow any traffic.
In the middleware section, we also allow JSON and url-encoded data through a request body and Express will parse it for us automatically.
Now if you go to Postman or any other HTTP client and perform a GET request on http://localhost:5000
, you will get the following JSON response:
{ "status": 200, "message": "API Works" }
If you see this message, your Express server is set up properly. Now let’s move on to the next step.
dotenv
Now let’s configure the dotenv
package so that it can properly recognize our environment variables from .env
file. Write the following code at the top:
require("dotenv").config();
Now let’s set up our connection to Stripe. Previously in the tutorial, we had installed a package named stripe
that will help us communicate with Stripe. But first, we need to provide it our Stripe secret key so that it can interact with our Stripe account.
Include this snippet on top of the file we created previously:
const Stripe = require("stripe"); const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
Earlier we dealt with environment variables, and here’s where we use the STRIPE_SECRET_KEY
we stored. Now Stripe recognizes your account and we can interact with Stripe further.
The entire code till should now display the following:
require("dotenv").config(); const express = require("express"); const app = express(); const PORT = process.env.PORT || 5000; const cors = require("cors"); const Stripe = require("stripe"); const stripe = Stripe(process.env.STRIPE_SECRET_KEY); app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.get("/", (req, res) => res.json({ status: 200, message: "API Works" })); app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Let’s think of what data we need to collect from the user to initiate the payment. We will keep things simple for the sake of this tutorial:
paymentMethod
, an ID generated by Stripe on the front end that will represent a specific cardonetime
or monthly
. We will set up recurring payments if the subscription is set to monthly
As we are “creating” a payment, we will use a POST request. Another reason to use POST request is that the data we send to the server is not shown in the URL itself, unlike a GET request. Plus, GET requests are directly accessible through the browser and that’s not something that we want.
So let’s create a POST request listener and collect data:
app.post("/donate", async (req, res) => { try { let { email, amount, paymentMethod, subscription } = req.body; if (!email || !amount || !paymentMethod || !subscription) return res.status(400).json({ status: 400, message: "All fields are required!" }); amount = parseInt(amount); if (subscription === "onetime") { // One time payment code here } if (subscription === "monthly") { // Recurring payment code here } res.status(400).json({ status: 400, message: "Invalid type" }); } catch(err) { console.error(err); res.status(500).json({ status: 200, message: "Internal server error" }); } });
In the above code we are doing the following:
/donate
route, of courseemail
, amount
, and paymentMethod
from the useramount
as a string, in which case we are converting the amount to an integer value using the parseInt()
functionFirst, we will deal with one-time payments.
We use 3D secure only if it’s required, or as per our radar rules in the Stripe dashboard. We must attempt an HTTP payment before we move on to 3D Secure because some cards do not support 3D Secure.
Now it’s time to contact Stripe:
const paymentIntent = await stripe.paymentIntents.create({ amount: Math.round(amount * 100), currency: "INR", receipt_email: email, description: "Payment for donation", payment_method: paymentMethod, confirm: true });
This initiates the payment right away. The confirm
field tells Stripe to confirm the payment as soon as it receives it. If you do not specify confirm
, it won’t charge the user, and you would need to confirm the order manually before making another request to Stripe.
In the amount
field, you specify the secondary currency unit (e.g., USD is cents and INR is paisa). Math.round()
is used here to remove any decimals, because Stripe doesn’t like decimal numbers.
Specify the currency according to your Stripe account location. For me it’s, India so I use INR
in currency.
Once the payment is completed, the receipt will be sent to email specified. In this case, we mention the email we collected from the user.
Now let’s check if this simple HTTP payment was successful. To do that we can check the status property of paymentIntent
:
if (paymentIntent.status === "succeeded") { // Payment successful! return res.json({ status: 200, message: "Payment Successful!", id: paymentIntent.id }); } >
That’s all for a simple HTTP payment. Here, paymentIntent.id
can be used as a payment ID. And we use return
to stop further execution immediately so that there are no unexpected errors.
However, if the status is not succeeded
but requires_action
, it means 3D Secure is required. So here’s how we will deal with 3D Secure:
client_secret
for the payment intentclient_secret
to the front endclient_secret
to authenticate using 3D secureclient_secret
and sending it to the front endLet’s check if the payment intent we created requires 3D secure, and then send the client secret:
if (paymentIntent.status === "requires_action") { return res.json({ status: 200, message: "3D secure required", actionRequired: true, clientSecret: paymentIntent.client_secret }); }
This way, we send the client secret to the front end. We will deal with the front end later in this article once we are done with the back end portion.
And finally, if the status is neither succeeded
nor requires_action
, we will inform the user that the payment has failed. We used return
in previous cases so we don’t need to use else
:
return res.status(400).json({ status: 400, message: "Payment failed!" });
We do not use payment intents directly in recurring payments. The process to create a recurring payment is a bit different:
Previously we created an if
statement for the monthly
subscription type. All the recurring payment code goes in there.
Let’s move on to the first step, creating a price:
const price = await stripe.prices.create({ unit_amount: Math.round(amount * 100), recurring: { interval: "month" }, currency: "INR", product_data: { name: "Recurring donation" } });
Here the unit_amount
is the actual amount – we already discussed how this is sent to Stripe.
We also provide recurring
with an interval
. In this case we set it to month
. The product_data
object contains some information about the product itself. In this case it’s just a donation, so we just specify it.
Now, let’s create the customer:
const customer = await stripe.customers.create({ email, description: "Donation customer", payment_method: paymentMethod, invoice_settings: { default_payment_method: paymentMethod } });
Here we specify the paymentMethod
so that we can charge the customer right away when needed without any complications.
This is where the customer is actually charged. When initiating a subscription, an invoice is generated which can be paid by the user, but we will make the user pay the invoice right away to start the subscription.
We can get the paymentIntent
from the subscription and then we can do checks as we did before:
const subscribe = await stripe.subscriptions.create({ customer: customer.id, items: [{ price: price.id }], expand: ["latest_invoice.payment_intent"] });
We are passing in the ID of customer and price, linking everything together. Also, to get access to the paymentIntent
of the latest invoice, we use the expand
property.
As we try to create a subscription, Stripe already attempts an HTTP-based payment. Now we need to take care of 3D secure payments the same way we did before:
if ( subscribe.latest_invoice.payment_intent.status === "requires_action" ) { // proceed to 3ds return res.status(200).json({ status: 200, message: "3D Secure required", actionRequired: true, clientSecret: subscribe.latest_invoice.payment_intent.client_secret, id: subscribe.latest_invoice.payment_intent.id, }); } if (subscribe.latest_invoice.payment_intent.status === "succeeded") { return res.json({ status: 200, message: "Payment successful!", }); } return res.status(400).json({ status: 400, message: "Payment failed!" });
It’s the same method we used for the one-time payments. We are done with the payment route in the backend.
There’s one more route to cover – the checking route. After authenticating on the front end, we need a route to check and verify the status with the back end:
app.get("/check/:id", async (req, res) => { try { const id = req.params.id; const paymentIntent = await stripe.paymentIntents.retrieve(id); if (paymentIntent?.status === "succeeded") { return res.json({ status: 200, message: "Payment successful!", id, }); } res .status(400) .json({ status: 200, message: "Payment failed! Please try again later.", }); } catch (err) { console.error(err); res.status(500).json({ status: 500, message: "Internal server error" }); } });
This time we use a GET request and check if the payment is actually complete. This can be done if you don’t want to use a webhook and want to provide a virtual service to the user right away.
This would be the place for your app to know that a payment is successful and the user is good to go. But in this case, this is a donation website and we don’t need to do anything special here.
index.js
codeYour index.js
file should now look like this:
require("dotenv").config(); const express = require("express"); const app = express(); const PORT = process.env.PORT || 5000; const cors = require("cors"); const Stripe = require("stripe"); const stripe = Stripe(process.env.STRIPE_SECRET_KEY); app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.get("/", (req, res) => res.json({ status: 200, message: "API Works" })); app.post("/donate", async (req, res) => { try { let { email, amount, paymentMethod, subscription } = req.body; if (!email || !amount || !paymentMethod || !subscription) return res.status(400).json({ status: 400, message: "All fields are required!" }); amount = parseInt(amount); if (subscription === "onetime") { // One time payment code here const paymentIntent = await stripe.paymentIntents.create({ amount: Math.round(amount * 100), currency: "INR", receipt_email: email, description: "Payment for donation", payment_method: paymentMethod, confirm: true }); if (paymentIntent.status === "succeeded") { // Payment successful! return res.json({ status: 200, message: "Payment Successful!", id: paymentIntent.id }); } if (paymentIntent.status === "requires_action") { return res.json({ status: 200, message: "3D secure required", actionRequired: true, clientSecret: paymentIntent.client_secret }); } return res.status(400).json({ status: 400, message: "Payment failed!" }); } if (subscription === "monthly") { // Recurring payment code here const price = await stripe.prices.create({ unit_amount: Math.round(amount * 100), recurring: { interval: "month" }, currency: "INR", product_data: { name: "Recurring donation" } }); const customer = await stripe.customers.create({ email, description: "Donation customer", payment_method: paymentMethod, invoice_settings: { default_payment_method: paymentMethod } }); const subscribe = await stripe.subscriptions.create({ customer: customer.id, items: [{ price: price.id }], expand: ["latest_invoice.payment_intent"] }); if ( subscribe.latest_invoice.payment_intent.status === "requires_action" ) { // proceed to 3ds return res.status(200).json({ status: 200, message: "3D Secure required", actionRequired: true, clientSecret: subscribe.latest_invoice.payment_intent.client_secret, id: subscribe.latest_invoice.payment_intent.id, }); } if (subscribe.latest_invoice.payment_intent.status === "succeeded") { return res.json({ status: 200, message: "Payment successful!", }); } return res.status(400).json({ status: 400, message: "Payment failed!" }); } res.status(400).json({ status: 400, message: "Invalid type" }); } catch(err) { console.error(err); res.status(500).json({ status: 200, message: "Internal server error" }); } }); app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Now let’s move on to the front end to learn how to trigger 3D Secure authentication and how to initiate payments.
We won’t do any fancy styling in the front end. Let’s keep it simple and focus on the payment side of things.
We will use React in the front end. Create a new folder called frontend
, open terminal in that folder, and type the following command:
npx create-react-app .
The .
specifies that we are creating a React app in the current folder itself.
Now lets install some packages that we will need while making this app:
npm install axios @stripe/react-stripe-js @stripe/stripe-js
axios
is a library to make HTTP requests easily without messing with the fetch
APINow open VSCode in the React app using the following command:
code .
Once in the integrated terminal, type the following command to start up the React App:
npm start
A new browser tab should open and you should see the following screen:
If you see this screen, you’ve successfully initiated a React application. Now let’s do some cleanup.
Delete the following files in src
which we don’t need:
App.test.js
setupTests.js
logo.svg
Once you delete these files, you’ll see an error pop up. That’s because we broke some things.
Go to App.js
and remove the logo import on the top and the content under the first div
. Remove everything in App.css
.
Your App.js
should look something like this:
import "./App.css"; function App() { return <div className="app"></div>; } export default App;
Next, let’s create a new component named Checkout
. Create two files in src
: Checkout.js
and Checkout.css
.
Since we are not focusing on styling in this tutorial, I am providing the contents of a CSS file but we will not go through what’s actually happening here in Checkout.css
:
.checkout { display: flex; align-items: center; justify-content: center; height: 100vh; width: 100%; } .checkout__container { background-color: #f5f5f5; padding: 20px; width: 25%; display: flex; flex-direction: column; } .checkout__textBox { padding: 10px; font-size: 18px; margin-bottom: 10px; } .checkout__radio { margin-bottom: 10px; } .checkout__btn { margin-top: 10px; padding: 10px; font-size: 18px; border: none; background-color: #0984e3; color: white; }
Now, open Checkout.js
and create a React functional component:
import React from "react"; function Checkout() { return <div className="checkout"></div>; } export default Checkout;
Now let’s import this component and use it in App.js
:
import { Elements } from "@stripe/react-stripe-js"; import { loadStripe } from "@stripe/stripe-js"; import "./App.css"; import Checkout from "./Checkout"; const stripePromise = loadStripe("(publishable key here)"); function App() { return ( <div className="app"> <Elements stripe={stripePromise}> <Checkout /> </Elements> </div> ); } export default App;
We wrap our Checkout
component inside the Elements
provided to us by Stripe. This component acts as a wrapper for all the Stripe elements and services we need.
We use the loadStripe()
function and pass in the publishable key, and then pass in the stripePromise
as stripe
in the Elements
component as props.
Now let’s go to Checkout.js
and make the basic layout of our form:
import { CardElement } from "@stripe/react-stripe-js"; import React, { useState } from "react"; function Checkout() { const [email, setEmail] = useState(""); const [amount, setAmount] = useState(""); const [subscription, setSubscription] = useState("onetime"); const handleSubmit = async (e) => { try { e.preventDefault(); } catch (error) { console.error(error); alert("Payment failed!"); } }; return ( <div className="checkout"> <form className="checkout__container" onSubmit={handleSubmit}> <input type="email" value={email} className="checkout__textBox" onChange={(e) => setEmail(e.target.value)} placeholder="E-mail Address" /> <input type="number" value={amount} className="checkout__textBox" onChange={(e) => setAmount(e.target.value)} placeholder="Amount" /> <div className="checkout__radio"> <input type="radio" onChange={(e) => setSubscription("onetime")} checked={subscription === "onetime"} /> Onetime </div> <div className="checkout__radio"> <input type="radio" onChange={(e) => setSubscription("monthly")} checked={subscription === "monthly"} /> Monthly </div> <CardElement options={{ style: { base: { fontSize: "16px", color: "#424770", "::placeholder": { color: "#aab7c4", }, }, invalid: { color: "#9e2146", }, }, }} /> <button className="checkout__btn" type="submit"> Donate </button> </form> </div> ); } export default Checkout;
We created a basic form asking for email and desired amount. The CardElement
component is used to show a little element for the user to enter card details.
Now let’s handle the event when the user submits the form:
const handleSubmit = async (e) => { try { e.preventDefault(); if (!elements || !stripe) return; const cardElement = elements.getElement(CardElement); const { error, paymentMethod } = await stripe.createPaymentMethod({ type: "card", card: cardElement, }); } catch (error) { console.error(error); alert("Payment failed!"); } };
First we will check if Stripe and Elements are loaded. If not, then the form will do nothing. How can you process a payment without Stripe being loaded?
Then we get to the cardElement
. The reason it’s too easy to find is because there can be only one CardElement
in the entire form.
Next, we create a paymentMethod
from the details entered in cardElement
, which in turn will return an object containing the payment method ID, which we require at the back end.
Now let’s hit our back end and process the payment.
Firstly let’s import axios
:
import axios from "axios"
Then, let’s make a request to our back end providing information about the payment:
const res = await axios.post("http://localhost:5000/donate", { amount, email, subscription, stripeToken: paymentMethod.id, });
If there’s an error in the request or the response code points to an error, the code will stop executing and go to the catch
block to handle the error.
Now the back end will attempt to perform simple HTTP payment and we will get a response. If we need 3D secure, actionRequired
will be true
:
if (res.data.actionRequired) { // We perform 3D Secure authentication const { paymentIntent, error } = await stripe.confirmCardPayment( res.data.clientSecret ); if (error) return alert("Error in payment, please try again later"); if (paymentIntent.status === "succeeded") return alert(`Payment successful, payment ID - ${res.data.id}`); const res2 = await axios.get(`http://localhost:5000/check/${res.data.id}`); alert(`Payment successful, payment ID - ${res.data.id}`); } else { // Simple HTTP Payment was successful alert(`Payment successful, payment ID - ${res.data.id}`); }
Here, we check if actionRequired
is true
. If it is, we need to trigger a 3D Secure authentication popup. We do that by passing in the clientSecret
we get from server to confirmCardPayment()
function from stripe
.
Then, we get back the paymentIntent
and check the payment from our server by sending the payment intent ID to the /check
route of our Express server. The route returns a 200 status code if the payment was successful, otherwise our code will go through the catch
block as explained before.
So that’s how you trigger 3D Secure. Here’s the complete code of Checkout.js
:
import { CardElement } from "@stripe/react-stripe-js"; import React, { useState } from "react"; import axios from "axios"; function Checkout() { const [email, setEmail] = useState(""); const [amount, setAmount] = useState(""); const [subscription, setSubscription] = useState("onetime"); const handleSubmit = async (e) => { try { e.preventDefault(); if (!elements || !stripe) return; const cardElement = elements.getElement(CardElement); const { error, paymentMethod } = await stripe.createPaymentMethod({ type: "card", card: cardElement, }); const res = await axios.post("http://localhost:5000/donate", { amount, email, subscription, stripeToken: paymentMethod.id, }); if (res.data.actionRequired) { // We perform 3D Secure authentication const { paymentIntent, error } = await stripe.confirmCardPayment( res.data.clientSecret ); if (error) return alert("Error in payment, please try again later"); if (paymentIntent.status === "succeeded") return alert(`Payment successful, payment ID - ${res.data.id}`); const res2 = await axios.get(`http://localhost:5000/check/${res.data.id}`); alert(`Payment successful, payment ID - ${res.data.id}`); } else { // Simple HTTP Payment was successful alert(`Payment successful, payment ID - ${res.data.id}`); } } catch (error) { console.error(error); alert("Payment failed!"); } }; return ( <div className="checkout"> <form className="checkout__container" onSubmit={handleSubmit}> <input type="email" value={email} className="checkout__textBox" onChange={(e) => setEmail(e.target.value)} placeholder="E-mail Address" /> <input type="number" value={amount} className="checkout__textBox" onChange={(e) => setAmount(e.target.value)} placeholder="Amount" /> <div className="checkout__radio"> <input type="radio" onChange={(e) => setSubscription("onetime")} checked={subscription === "onetime"} /> Onetime </div> <div className="checkout__radio"> <input type="radio" onChange={(e) => setSubscription("monthly")} checked={subscription === "monthly"} /> Monthly </div> <CardElement options={{ style: { base: { fontSize: "16px", color: "#424770", "::placeholder": { color: "#aab7c4", }, }, invalid: { color: "#9e2146", }, }, }} /> <button className="checkout__btn" type="submit"> Donate </button> </form> </div> ); } export default Checkout;
To test your Stripe integration, here are some card details provided by Stripe to test. You need to be on test mode to use these cards, and you will not be charged.
A popup will be opened when you enter the card with 3D Secure. In production environments, the user will be sent an SMS to their phone number to authenticate the payment.
You can set your Radar rules to force 3D Secure for supported cards, just be aware that Radar rules are not available for all countries.
I recommend checking out more from Stripe, like Apple Pay, Google Pay, saved cards, off-session payment, and the multiple other payment methods offered.
You can also check out Stripe Checkout, where you just need to pass in products and the payment will be handled by Stripe.
LogRocket is like a DVR for web and mobile apps and websites, recording literally everything that happens on your ecommerce app. Instead of guessing why users don’t convert, LogRocket proactively surfaces the root cause of issues that are preventing conversion in your funnel, such as JavaScript errors or dead clicks. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Start proactively monitoring your ecommerce apps — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
2 Replies to "Implementing 3D Secure in Stripe"
This was an amazingly useful article!!! absolutely loved it
I have followed all the steps mentioned in this article but when I am making the Stripe payment in my React App, after the handleSubmit function, I’m getting this alert – “Payment successful, payment ID – undefined”. Why is my payment ID showing as “undefined”? And most importantly, how to fix this error?