Learn how to securely restrict Stripe API keys on the frontend by keeping secret keys server-side, using restricted publishable keys, and implementing best security practices.
Book a call with an Expert
Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.
How to Restrict Stripe API Keys on Frontend
Introduction
Exposing Stripe API keys on the frontend is a significant security risk that can lead to unauthorized access, fraudulent transactions, and potential financial loss. This tutorial provides comprehensive steps to properly secure your Stripe implementation by keeping sensitive API keys off the client-side and implementing a secure server-side approach.
Step 1: Understanding the Security Risk
First, let's understand why exposing Stripe API keys in frontend code is dangerous:
Step 2: Set Up a Backend API Server
The most secure approach is to handle all Stripe operations on your server. Create a backend service using Node.js, Python, Ruby, or any other server-side language.
Example using Node.js and Express:
// server.js
const express = require('express');
const stripe = require('stripe')('sk_test_YourSecretKeyHere');
const cors = require('cors');
const app = express();
// Middleware
app.use(express.json());
app.use(cors({
origin: 'https://your-trusted-frontend-domain.com' // Restrict to your frontend domain
}));
// Secure payment endpoint
app.post('/api/create-payment-intent', async (req, res) => {
try {
const { amount, currency, customer } = req.body;
// Validate inputs here
if (!amount || amount <= 0) {
return res.status(400).json({ error: 'Invalid amount' });
}
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
customer,
// Additional options as needed
});
res.json({ clientSecret: paymentIntent.client\_secret });
} catch (error) {
console.error('Payment intent error:', error);
res.status(500).json({ error: error.message });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Step 3: Secure Your Stripe API Keys
Never hardcode your Stripe secret key in your code. Use environment variables instead:
// Using environment variables
require('dotenv').config(); // Make sure to install dotenv package
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
Create a .env file (not checked into version control):
# .env file
STRIPE_SECRET_KEY=sk_test_YourSecretKeyHere
STRIPE_PUBLISHABLE_KEY=pk_test_YourPublishableKeyHere
Add .env to your .gitignore file:
# .gitignore
.env
node\_modules/
Step 4: Implement Frontend Code
In your frontend code, only use the Stripe publishable key and communicate with your backend:
// Frontend JavaScript (React example)
import React, { useState, useEffect } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { CardElement, Elements, useStripe, useElements } from '@stripe/react-stripe-js';
// Initialize Stripe with publishable key only
const stripePromise = loadStripe('pk_test_YourPublishableKeyHere');
const CheckoutForm = () => {
const stripe = useStripe();
const elements = useElements();
const [clientSecret, setClientSecret] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// Call your backend to create a PaymentIntent and get the clientSecret
fetch('https://your-backend-api.com/api/create-payment-intent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 1000, // $10.00
currency: 'usd',
}),
})
.then((res) => res.json())
.then((data) => {
setClientSecret(data.clientSecret);
})
.catch((err) => {
setError('Error preparing payment. Please try again.');
console.error(err);
});
}, []);
const handleSubmit = async (event) => {
event.preventDefault();
setLoading(true);
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment\_method: {
card: cardElement,
billing\_details: {
name: 'Customer Name',
},
},
});
setLoading(false);
if (error) {
setError(error.message);
} else if (paymentIntent.status === 'succeeded') {
// Payment successful - update UI or redirect
console.log('Payment succeeded!');
}
};
return (
);
};
const StripeCheckout = () => (
);
export default StripeCheckout;
Step 5: Implement Proper CORS Settings
Configure Cross-Origin Resource Sharing (CORS) on your backend to only allow requests from your trusted domains:
// Detailed CORS configuration for Express
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
'https://your-production-domain.com',
'https://www.your-production-domain.com'
];
// During development, you might want to allow localhost
if (process.env.NODE\_ENV === 'development') {
allowedOrigins.push('http://localhost:3000');
}
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // 24 hours
};
app.use(cors(corsOptions));
Step 6: Restrict Stripe Publishable Key Usage
Even though the publishable key is designed to be public, you can restrict its capabilities in the Stripe Dashboard:
Step 7: Use Stripe Elements for Additional Security
Stripe Elements handles sensitive card data on Stripe's servers rather than yours:
// Include Stripe.js before using Stripe Elements
// Initialize Elements
const stripe = Stripe('pk_test_YourRestrictedPublishableKey');
const elements = stripe.elements();
// Create card element
const cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#32325d',
}
}
});
// Mount the card element to the DOM
cardElement.mount('#card-element');
// Handle form submission
document.getElementById('payment-form').addEventListener('submit', async (event) => {
event.preventDefault();
const { paymentMethod, error } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (error) {
// Handle error
console.error(error);
} else {
// Send paymentMethod.id to your server
const response = await fetch('/api/process-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
payment_method_id: paymentMethod.id,
amount: 1000, // $10.00
}),
});
const result = await response.json();
// Handle server response
if (result.success) {
// Payment succeeded
} else {
// Payment failed
}
}
});
Step 8: Implement Strong Authentication
Secure your API endpoints with proper authentication to ensure only authorized users can initiate payment operations:
// Authentication middleware (example using JWT)
const jwt = require('jsonwebtoken');
const authenticateUser = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT\_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
};
// Apply middleware to payment routes
app.post('/api/create-payment-intent', authenticateUser, async (req, res) => {
// Now you have access to the authenticated user via req.user
// You can use this to ensure the payment is for the correct user
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: req.body.amount,
currency: req.body.currency,
customer: req.user.stripeCustomerId, // Use the authenticated user's ID
// Additional options
});
res.json({ clientSecret: paymentIntent.client\_secret });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Step 9: Set Up Webhook Handling
Implement Stripe webhooks to handle asynchronous payment events securely:
// Webhook handling endpoint
app.post('/stripe-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error(`Webhook error: ${err.message}`);
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 for ${paymentIntent.amount} was successful!`);
// Update your database, fulfill orders, etc.
break;
case 'payment_intent.payment_failed':
const failedPayment = event.data.object;
console.log(`Payment failed: ${failedPayment.last_payment_error?.message}`);
// Notify customer of failed payment
break;
default:
// Unexpected event type
console.log(`Unhandled event type ${event.type}`);
}
// Return a 200 response to acknowledge receipt of the event
res.json({ received: true });
});
Step 10: Implement Additional Security Measures
Example rate limiting implementation:
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 _ 60 _ 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: 'Too many requests from this IP, please try again after 15 minutes'
});
// Apply rate limiting to all payment-related routes
app.use('/api/', apiLimiter);
Conclusion
By following these steps, you've created a secure implementation for Stripe payments that keeps sensitive API keys off the frontend. Remember that security is an ongoing process, and you should regularly review and update your implementation to address new security challenges and comply with Stripe's best practices.
When it comes to serving you, we sweat the little things. That’s why our work makes a big impact.