Introduction
PCI DSS compliance can feel like a maze of regulations designed to confuse developers. The good news is that with Stripe and a few core principles, you can keep your application out of scope and let a PCI-certified provider handle the heavy lifting. This practical guide for developers cuts through the noise and focuses on what actually matters: keeping your systems secure without reinventing payment infrastructure.
We've seen teams struggle with compliance not because they lacked good intentions, but because they didn't understand the leverage points where a single decision—like choosing Stripe Elements over a custom form—transforms your entire security posture. Let's walk through the architecture and code patterns that keep you compliant without slowing you down.
Never touch raw card data — Use Stripe Elements or Checkout
The simplest rule in payment engineering is also the most powerful: your servers should never see a card number, expiry date, or CVC code. Not in logs, not in memory, not ever. When raw card data touches your infrastructure, you inherit PCI DSS scope immediately.
Stripe Elements and Stripe Checkout handle this for you. Card data flows directly from the browser to Stripe's servers, encrypted, and your backend receives a token that represents the card. That token is what you work with in your API calls.
Here's a typical flow using Stripe Elements with React:
import React, { useState } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
export function PaymentForm() {
const stripe = useStripe();
const elements = useElements();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
// Create a payment method token without touching raw card data
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: elements.getElement(CardElement),
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
// Send only the token to your backend
const response = await fetch('/api/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentMethodId: paymentMethod.id,
amount: 2999, // $29.99 in cents
}),
});
const result = await response.json();
if (result.success) {
// Handle success
}
};
return (
<form onSubmit={handleSubmit}>
<CardElement />
<button disabled={!stripe || loading}>Pay</button>
{error && <p>{error}</p>}
</form>
);
}
Your backend then processes the token:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function chargeCustomer(
paymentMethodId: string,
amountCents: number
) {
const paymentIntent = await stripe.paymentIntents.create({
amount: amountCents,
currency: 'usd',
payment_method: paymentMethodId,
confirm: true,
});
return paymentIntent;
}
Notice: your backend never constructs or deconstructs card data. You pass tokens around and let Stripe handle the cryptography. This architectural boundary is your primary defense.
SAQ A — Hosted fields keep most apps in the simplest scope
PCI DSS has different questionnaires (SAQs) depending on how you handle payment data. SAQ A is the easiest, and it applies to most merchants who use hosted solutions like Stripe Checkout or Stripe Elements.
SAQ A means you only answer basic questions: Do you store card data? No. Do you handle raw card numbers? No. Do you have a firewall? Yes. That's it. No deep network scans, no intrusive audits, no expensive compliance consultants.
Contrast this with SAQ D, which applies if you're building your own payment form that touches card data directly. SAQ D requires annual penetration testing, network segmentation, detailed logging policies, and PCI-DSS certification audits. It's a multi-month, multi-thousand-dollar undertaking annually.
The path to SAQ A is clear: use Stripe Checkout or Elements. Here's a minimal Checkout integration:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function createCheckoutSession(
customerId: string,
items: Array<{ priceId: string; quantity: number }>
) {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'payment',
line_items: items,
success_url: `${process.env.DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.DOMAIN}/cancel`,
});
return session;
}
The customer never leaves your domain visually (Checkout is embedded), but the payment form itself runs in an iframe from Stripe's PCI-certified servers. Your backend never touches the form submission directly. You create a session, pass the session ID to the frontend, and Stripe handles the rest. This is SAQ A territory.
Secure keys — Secret keys only on servers
Stripe provides two API keys: publishable and secret. Publishable keys are safe to embed in your frontend code—they can only perform read-only operations and create client-side tokens. Secret keys must never be exposed to the browser. They live only in your backend environment variables, protected by your infrastructure.
A common mistake is hardcoding keys or committing them to version control. Use environment variable management:
// .env.local (gitignored)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_abc123...
STRIPE_SECRET_KEY=sk_test_xyz789...
// frontend/components/StripeProvider.tsx
import { loadStripe } from '@stripe/js';
import { Elements } from '@stripe/react-stripe-js';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
);
export function StripeProvider({ children }) {
return <Elements stripe={stripePromise}>{children}</Elements>;
}
// backend/api/charge.ts
// Only the backend sees STRIPE_SECRET_KEY
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-04-10',
});
When you rotate keys, do so through Stripe's dashboard, then update your environment. Never log keys, never commit them, never share them over Slack or email.
In production, use your cloud provider's secrets manager. AWS Secrets Manager, GCP Secret Manager, or Azure Key Vault ensure keys are encrypted at rest and in transit, with audit logs of every access.
Logs — Never log card numbers or full payment payloads
Logging is essential for debugging, but logging payment data defeats the entire purpose of using Stripe. If a card number ends up in your CloudWatch logs, Splunk instance, or error tracking service, you've just created a PCI liability.
Be explicit about what you log and what you sanitize:
import pino from 'pino';
const logger = pino({
redact: {
paths: [
'req.body.card_number',
'req.body.cvc',
'req.body.exp_month',
'req.body.exp_year',
'paymentIntent.payment_method_details.card',
'error.card_number',
],
remove: true, // remove entirely instead of replacing with [REDACTED]
},
});
export async function processPayment(paymentIntentId: string, userId: string) {
try {
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
// Log only metadata and IDs, never the payment method details
logger.info({
event: 'payment_processed',
userId,
paymentIntentId,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
status: paymentIntent.status,
// Intentionally omit: paymentIntent.payment_method_details
});
return paymentIntent;
} catch (err) {
logger.error({
event: 'payment_failed',
userId,
paymentIntentId,
errorCode: err.code,
errorMessage: err.message,
// Never log the full error object; it might contain card data
});
throw err;
}
}
The same principle applies to error tracking services like Sentry. Configure client hints to redact payment data before it's sent:
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
beforeSend(event) {
// Strip payment-related fields from errors
if (event.request) {
delete event.request.data;
}
if (event.contexts?.trace) {
event.contexts.trace.op = 'payment';
// Don't log the actual payload
}
return event;
},
});
Audit your logs regularly. Query CloudWatch, grep production logs, or use your SIEM to confirm no card data is leaking. Make this part of your weekly security check.
Common mistakes
- Building a custom payment form that submits to your own backend. This immediately puts you in SAQ D scope. Use Stripe Elements or Checkout.
- Logging the full Stripe API response. Even though Stripe redacts sensitive data in its JSON, logging the entire response is sloppy. Log only the fields you need.
- Storing Stripe API responses in your database without filtering. If you cache a PaymentIntent or Customer object that includes payment method details, you're storing payment data. Stripe doesn't redact when you retrieve from the API; it's your responsibility to filter.
- Mixing publishable and secret keys. If you see a publishable key used in a backend request, or a secret key exposed in frontend code, fix it immediately and rotate both keys.
- Ignoring webhook security. Always verify webhook signatures. Attackers can forge webhooks if you trust them blindly. Stripe sends a signature header; verify it server-side before processing.
Key takeaways
- Never let raw card data touch your application. Use Stripe Elements or Checkout to keep payment processing client-side and token-based.
- SAQ A compliance (the simplest path) requires you to prove you don't handle raw card data. Hosted solutions get you there automatically.
- Secret keys live in environment variables on your backend only. Publishable keys can be public. Rotate keys regularly through Stripe's dashboard.
- Redact payment data from all logs, error tracking, and monitoring systems. Be explicit in your logging configuration about which fields to exclude.
- Compliance is not a checklist you complete once; it's an ongoing practice. Audit your code regularly for card data leaks, especially when onboarding new team members.






Comments
Loading comments…