Stripe API calls from the browser fail with CORS errors because Stripe's servers do not allow cross-origin requests from frontend code. The fix is to never call the Stripe API directly from the browser. Instead, create a server-side endpoint that makes the Stripe API call and return the result to your frontend. Use Stripe.js and Elements for client-side payment collection.
Why Stripe Blocks Frontend API Calls
Stripe intentionally does not set CORS headers on its API responses because the secret key (sk_) should never be in frontend code. If Stripe allowed browser-side API calls, your secret key would be exposed to anyone viewing the page source. The correct architecture is: frontend uses Stripe.js with the publishable key (pk_) for payment collection, then sends a request to YOUR server, which calls the Stripe API with the secret key.
Prerequisites
- A Stripe account with API keys
- Node.js 18+ with Express for the backend
- A frontend application (React, Vue, vanilla JS, etc.)
- Understanding of client-server communication
Step-by-step guide
Understand the correct Stripe payment flow
Understand the correct Stripe payment flow
The payment flow should be: (1) Frontend loads Stripe.js with your publishable key (pk_test_), (2) Customer enters card details in Stripe Elements, (3) Frontend sends the payment method ID or amount to YOUR server, (4) Your server creates the PaymentIntent using the secret key (sk_test_), (5) Your server returns the client secret to the frontend for confirmation.
Expected result: You understand that Stripe API calls happen server-side, while Stripe.js handles secure card collection on the frontend.
Remove Stripe secret key calls from frontend code
Remove Stripe secret key calls from frontend code
Search your frontend code for any direct calls to api.stripe.com or usage of your secret key (sk_). These must be moved to your backend server. The publishable key (pk_) is safe for the frontend.
1// WRONG — this will cause CORS errors and expose your secret key2const response = await fetch('https://api.stripe.com/v1/payment_intents', {3 method: 'POST',4 headers: {5 'Authorization': 'Bearer sk_test_...', // NEVER do this6 },7});89// RIGHT — call YOUR server instead10const response = await fetch('/api/create-payment-intent', {11 method: 'POST',12 headers: { 'Content-Type': 'application/json' },13 body: JSON.stringify({ amount: 2000, currency: 'usd' }),14});Expected result: All Stripe API calls are removed from frontend code and replaced with calls to your own backend endpoints.
Create a server-side endpoint for PaymentIntent creation
Create a server-side endpoint for PaymentIntent creation
Add an Express endpoint that creates PaymentIntents on behalf of the frontend. The secret key stays on the server, and the frontend only receives the client_secret needed to confirm the payment.
1const express = require('express');2const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);3const cors = require('cors');45const app = express();6app.use(cors({ origin: 'http://localhost:3000' })); // Your frontend origin7app.use(express.json());89app.post('/api/create-payment-intent', async (req, res) => {10 const { amount, currency } = req.body;1112 const paymentIntent = await stripe.paymentIntents.create({13 amount, // in cents: 2000 = $20.0014 currency: currency || 'usd',15 automatic_payment_methods: { enabled: true },16 });1718 // Only send the client_secret — NEVER send the full PaymentIntent19 res.json({ clientSecret: paymentIntent.client_secret });20});Expected result: Your server endpoint creates a PaymentIntent and returns only the client_secret to the frontend.
Set up Stripe.js and Elements on the frontend
Set up Stripe.js and Elements on the frontend
Load Stripe.js with your publishable key and use Stripe Elements for secure card collection. The publishable key (pk_) is designed for frontend use and does not expose any sensitive data.
1// Frontend code (React example)2import { loadStripe } from '@stripe/stripe-js';3import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';45// pk_ key is safe for the frontend6const stripePromise = loadStripe('pk_test_your_publishable_key');78function CheckoutForm() {9 const stripe = useStripe();10 const elements = useElements();1112 const handleSubmit = async (e) => {13 e.preventDefault();1415 // 1. Create PaymentIntent on YOUR server16 const res = await fetch('/api/create-payment-intent', {17 method: 'POST',18 headers: { 'Content-Type': 'application/json' },19 body: JSON.stringify({ amount: 2000, currency: 'usd' }),20 });21 const { clientSecret } = await res.json();2223 // 2. Confirm payment with Stripe.js24 const { error } = await stripe.confirmPayment({25 elements,26 clientSecret,27 confirmParams: { return_url: 'http://localhost:3000/success' },28 });2930 if (error) console.error(error.message);31 };3233 return (34 <form onSubmit={handleSubmit}>35 <PaymentElement />36 <button type="submit">Pay</button>37 </form>38 );39}Expected result: The frontend collects card details via Stripe Elements and confirms the payment using the client_secret from your server.
Complete working example
1require('dotenv').config();2const express = require('express');3const cors = require('cors');4const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);56const app = express();78// Allow your frontend origin9app.use(cors({10 origin: process.env.FRONTEND_URL || 'http://localhost:3000',11}));12app.use(express.json());1314// Create PaymentIntent — server-side only15app.post('/api/create-payment-intent', async (req, res) => {16 try {17 const { amount, currency } = req.body;1819 if (!amount || amount < 50) {20 return res.status(400).json({ error: 'Amount must be at least 50 cents' });21 }2223 const paymentIntent = await stripe.paymentIntents.create({24 amount,25 currency: currency || 'usd',26 automatic_payment_methods: { enabled: true },27 });2829 res.json({ clientSecret: paymentIntent.client_secret });30 } catch (err) {31 console.error('PaymentIntent error:', err.message);32 res.status(500).json({ error: 'Failed to create payment intent' });33 }34});3536// List prices — server-side proxy for product data37app.get('/api/prices', async (req, res) => {38 try {39 const prices = await stripe.prices.list({40 active: true,41 expand: ['data.product'],42 limit: 10,43 });44 res.json(prices.data);45 } catch (err) {46 res.status(500).json({ error: 'Failed to fetch prices' });47 }48});4950const PORT = process.env.PORT || 4000;51app.listen(PORT, () => console.log(`API server on port ${PORT}`));Common mistakes when fixing CORS issues when using Stripe API on frontend
Why it's a problem: Calling api.stripe.com directly from browser JavaScript
How to avoid: Never call the Stripe API from the frontend. Create a server-side endpoint that makes the API call and returns only the data the frontend needs.
Why it's a problem: Putting the Stripe secret key (sk_) in frontend code
How to avoid: The secret key must only exist on your server. Use the publishable key (pk_) for Stripe.js on the frontend. If your secret key was exposed, rotate it immediately in the Dashboard.
Why it's a problem: Trying to add CORS headers to Stripe's API via a proxy
How to avoid: Don't proxy Stripe API calls just to add CORS headers. The correct fix is to call Stripe from your server. Proxying still risks exposing your secret key.
Why it's a problem: Returning the full PaymentIntent object to the frontend
How to avoid: Only return the client_secret to the frontend. The full PaymentIntent contains metadata and information that should stay server-side.
Best practices
- Never call the Stripe API directly from browser code — always use a server-side proxy
- Use the publishable key (pk_) on the frontend and the secret key (sk_) on the server only
- Set up CORS on YOUR server to allow your frontend's origin
- Use Stripe.js and Elements for secure, PCI-compliant card collection
- Return only the client_secret from your server — never the full PaymentIntent
- Validate amounts and currencies on the server before creating PaymentIntents
- Use environment variables for both keys — never hard-code them
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm getting CORS errors when calling the Stripe API from my React frontend. Write a Node.js Express server that creates PaymentIntents server-side and returns the clientSecret. Show the matching React frontend code using @stripe/react-stripe-js and PaymentElement. Use pk_ on the frontend and sk_ on the server only.
Build a Stripe payment integration with a Node.js Express backend and React frontend. The server creates PaymentIntents with the secret key. The frontend uses Stripe.js with the publishable key and PaymentElement for card collection. Include CORS configuration.
Frequently asked questions
Why does Stripe block CORS requests?
Stripe blocks browser-side API calls because the secret key (sk_) should never be in frontend code. Allowing CORS would encourage developers to put secret keys in client-side JavaScript, which is a major security risk.
Can I use a CORS proxy to call the Stripe API from the frontend?
You should not. While technically possible, a CORS proxy still requires your secret key, creating a security risk. The correct approach is to create your own server endpoint that calls the Stripe API.
Is the publishable key (pk_) safe to use in frontend code?
Yes. The publishable key is designed for client-side use. It can only be used to create tokens and confirm payments — it cannot read customer data, create charges, or perform any sensitive operations.
Do I need CORS on my own server?
Yes. If your frontend and backend are on different origins (e.g., localhost:3000 and localhost:4000), you need to configure CORS on your server using the cors npm package to allow your frontend's origin.
What is the correct Stripe payment flow?
Frontend collects card details via Stripe Elements (pk_ key) → frontend sends amount to your server → server creates PaymentIntent (sk_ key) → server returns client_secret → frontend confirms payment with Stripe.js.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation