Never store raw card numbers on your server. Stripe handles PCI compliance through tokenization — card details are collected client-side with Stripe Elements or Checkout, converted to tokens or PaymentMethods, and only the token ID touches your backend. This guide covers SetupIntents for saving cards, PaymentMethods for reuse, and how Stripe's tokenization keeps you PCI-compliant.
Secure Card Storage with Stripe Tokenization
PCI DSS (Payment Card Industry Data Security Standard) requires any business handling card data to follow strict security rules. Stripe eliminates most of this burden through tokenization. Card numbers never touch your server — they go directly from the customer's browser to Stripe's PCI-compliant infrastructure. Your server only ever handles opaque token IDs like pm_1ABC or tok_1XYZ.
Prerequisites
- A Stripe account (test mode is fine)
- Node.js 18 or later installed
- Stripe Node.js SDK: npm install stripe
- A basic frontend with Stripe.js loaded
Step-by-step guide
Understand the tokenization flow
Understand the tokenization flow
Card details flow from the customer's browser directly to Stripe via Stripe.js. Stripe returns a token or PaymentMethod ID that your server uses. Your server never sees the full card number.
1// Frontend: Load Stripe.js and create Elements2// <script src="https://js.stripe.com/v3/"></script>34// Initialize with your PUBLISHABLE key (pk_test_...)5const stripe = Stripe('pk_test_yourPublishableKey');6const elements = stripe.elements();7const cardElement = elements.create('card');8cardElement.mount('#card-element');910// When the form is submitted, create a PaymentMethod11async function handleSubmit() {12 const { paymentMethod, error } = await stripe.createPaymentMethod({13 type: 'card',14 card: cardElement,15 });1617 if (error) {18 console.error(error.message);19 return;20 }2122 // Send ONLY the paymentMethod.id to your server23 await fetch('/api/save-card', {24 method: 'POST',25 headers: { 'Content-Type': 'application/json' },26 body: JSON.stringify({ paymentMethodId: paymentMethod.id }),27 });28}Expected result: A PaymentMethod ID like pm_1ABC is created client-side and sent to your server. No card numbers touch your backend.
Create a SetupIntent to save a card for later
Create a SetupIntent to save a card for later
SetupIntents handle the flow of saving a card without charging it. They also handle 3D Secure authentication if required by the card issuer.
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);23// Server: Create a SetupIntent4async function createSetupIntent(customerId) {5 const setupIntent = await stripe.setupIntents.create({6 customer: customerId,7 payment_method_types: ['card'],8 });9 return setupIntent.client_secret; // Send to frontend10}1112// Frontend: Confirm the SetupIntent with the card element13async function confirmSetup(clientSecret) {14 const { setupIntent, error } = await stripe.confirmCardSetup(clientSecret, {15 payment_method: {16 card: cardElement,17 },18 });1920 if (error) {21 console.error(error.message);22 } else {23 console.log('Card saved:', setupIntent.payment_method);24 }25}Expected result: The card is tokenized, authenticated if needed, and attached to the customer for future charges.
Attach a PaymentMethod to a customer
Attach a PaymentMethod to a customer
After creating a PaymentMethod on the frontend, attach it to a Stripe Customer on the backend so you can charge it later.
1async function saveCardToCustomer(paymentMethodId, customerId) {2 // Attach the PaymentMethod to the customer3 await stripe.paymentMethods.attach(paymentMethodId, {4 customer: customerId,5 });67 // Optionally set as default payment method8 await stripe.customers.update(customerId, {9 invoice_settings: {10 default_payment_method: paymentMethodId,11 },12 });1314 console.log(`Card ${paymentMethodId} saved to customer ${customerId}`);15}1617saveCardToCustomer('pm_1ABC', 'cus_customer123');Expected result: The PaymentMethod is attached to the customer and set as the default for future invoices and charges.
Charge a saved card
Charge a saved card
Create a PaymentIntent with the customer and their saved PaymentMethod to charge the card without collecting details again.
1async function chargeSavedCard(customerId, paymentMethodId, amount) {2 const paymentIntent = await stripe.paymentIntents.create({3 amount: amount, // in cents4 currency: 'usd',5 customer: customerId,6 payment_method: paymentMethodId,7 off_session: true, // Customer is not present8 confirm: true,9 });1011 console.log('Payment succeeded:', paymentIntent.id);12 return paymentIntent;13}1415chargeSavedCard('cus_customer123', 'pm_1ABC', 2500); // $25.00Expected result: The saved card is charged without requiring the customer to re-enter card details.
Complete working example
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);2const express = require('express');3const app = express();45app.use(express.json());6app.use(express.static('public'));78// Create a customer and SetupIntent for saving a card9app.post('/api/setup-card', async (req, res) => {10 try {11 const { email } = req.body;1213 // Create or retrieve customer14 let customer;15 const existing = await stripe.customers.list({ email, limit: 1 });16 if (existing.data.length > 0) {17 customer = existing.data[0];18 } else {19 customer = await stripe.customers.create({ email });20 }2122 // Create SetupIntent23 const setupIntent = await stripe.setupIntents.create({24 customer: customer.id,25 payment_method_types: ['card'],26 });2728 res.json({29 clientSecret: setupIntent.client_secret,30 customerId: customer.id,31 });32 } catch (err) {33 res.status(400).json({ error: err.message });34 }35});3637// List saved cards for a customer38app.get('/api/cards/:customerId', async (req, res) => {39 try {40 const methods = await stripe.paymentMethods.list({41 customer: req.params.customerId,42 type: 'card',43 });4445 const cards = methods.data.map(pm => ({46 id: pm.id,47 brand: pm.card.brand,48 last4: pm.card.last4,49 expMonth: pm.card.exp_month,50 expYear: pm.card.exp_year,51 }));5253 res.json(cards);54 } catch (err) {55 res.status(400).json({ error: err.message });56 }57});5859// Charge a saved card60app.post('/api/charge-saved', async (req, res) => {61 try {62 const { customerId, paymentMethodId, amount } = req.body;63 const pi = await stripe.paymentIntents.create({64 amount,65 currency: 'usd',66 customer: customerId,67 payment_method: paymentMethodId,68 off_session: true,69 confirm: true,70 });71 res.json({ paymentIntentId: pi.id, status: pi.status });72 } catch (err) {73 if (err.code === 'authentication_required') {74 res.status(402).json({75 error: 'Authentication required',76 paymentIntentId: err.raw.payment_intent.id,77 });78 } else {79 res.status(400).json({ error: err.message });80 }81 }82});8384app.listen(3000, () => console.log('Server on port 3000'));Common mistakes when storing card details securely with Stripe
Why it's a problem: Logging or storing full card numbers in your database
How to avoid: Never store raw card data. Use Stripe's PaymentMethod IDs instead. You can store last4 and brand for display purposes.
Why it's a problem: Using the secret key (sk_) on the frontend
How to avoid: Only use the publishable key (pk_test_ or pk_live_) in browser code. Secret keys must stay on your server.
Why it's a problem: Not handling authentication_required errors on off-session charges
How to avoid: Some saved cards require 3D Secure authentication. Catch the error and redirect the customer to complete authentication.
Why it's a problem: Creating PaymentMethods server-side with raw card data
How to avoid: Always create PaymentMethods client-side with Stripe.js or Elements. Server-side creation with raw card data increases your PCI scope.
Best practices
- Always collect card details with Stripe Elements or Checkout to stay PCI-compliant at SAQ-A level
- Use SetupIntents (not raw tokens) when saving cards — they handle 3D Secure authentication
- Store only the PaymentMethod ID, card brand, and last 4 digits in your database
- Set a default_payment_method on the customer for seamless recurring charges
- Handle authentication_required errors gracefully for off-session charges
- Use test card 4242424242424242 with any future expiry and any CVC for testing
- Delete saved PaymentMethods when customers request removal of their card data
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
How do I securely store credit card details with Stripe? I want to save a card for future charges without storing raw card numbers. Show me the SetupIntent and PaymentMethod flow with Node.js.
Build a card-saving flow for my app using Stripe. I need a SetupIntent to save cards, a way to list saved cards, and an endpoint to charge saved cards off-session. Use Node.js and Express with Stripe Elements on the frontend.
Frequently asked questions
What PCI compliance level do I need when using Stripe Elements?
SAQ-A, the simplest level. Card data goes directly from the browser to Stripe and never touches your server. You just need to fill out a self-assessment questionnaire annually.
Can I store the card number in my database if I encrypt it?
No. Even encrypted card storage puts you at PCI SAQ-D level, requiring extensive security audits. Use Stripe's PaymentMethod IDs instead.
How do I let customers update their saved card?
Create a new SetupIntent, let the customer enter the new card, and then detach the old PaymentMethod. You cannot update card details on an existing PaymentMethod.
What is the difference between a Token and a PaymentMethod?
Tokens (tok_) are the older API. PaymentMethods (pm_) are the current standard and support SetupIntents, 3D Secure, and more card types. Always use PaymentMethods for new integrations.
How long does a saved PaymentMethod last?
PaymentMethods attached to a customer persist until the card expires, is declined, or you explicitly detach it. Stripe automatically updates card details for some banks via card account updater.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation