Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 042 / 100

PCI DSS for developers: a practical guide for developers

Stay out of scope as much as possible.

Expona Admin8 min read

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:

typescript
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:

typescript
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:

typescript
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:

typescript
// .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:

typescript
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:

typescript
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles