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

Data privacy in payments: a hands-on guide for engineers

Protect customer data.

Expona Admin9 min read

Payment systems handle the most sensitive customer information your platform processes—and a breach doesn't just damage your reputation, it can trigger regulatory fines, lawsuits, and loss of customer trust. Data privacy in payments: a hands-on guide for engineers explores the architectural and operational decisions that keep payment data secure without sacrificing functionality. We'll walk through what to store, what to discard, who gets access, and how long to keep records.

Why payment data privacy matters differently

Payment data differs from other sensitive information because it's regulated by multiple frameworks simultaneously. PCI DSS (Payment Card Industry Data Security Standard) sets baseline technical requirements. GDPR, CCPA, and regional laws govern retention and customer rights. Your payment processor (Stripe, PayPal, etc.) has its own compliance obligations that flow back to you.

More importantly, payment data is a constant target. Threat actors know that credit card numbers, expiration dates, and CVVs convert directly to money. Unlike a compromised email address or phone number, stolen payment credentials have immediate monetary value. We've seen breaches at major retailers cost hundreds of millions—not because engineers were incompetent, but because they stored more than they needed to.

The principle we follow is simple: never store what you don't need. Everything else flows from that.

Store only provider IDs

Your payment processor assigns a unique identifier to every payment instrument and transaction. Stripe calls this the "payment method ID" (pm_xxxxx) and the "charge ID" (ch_xxxxx). PayPal uses "billing agreement IDs." These IDs are your safe bridge between your system and the payment network.

When a customer adds a card, your frontend tokenizes it using Stripe's client-side library. The card never touches your servers. Stripe returns a payment method ID. You store that ID in your database—nothing else.

typescript

// ✅ CORRECT: Store only the provider ID
interface StoredPaymentMethod {
  id: string; // your internal ID
  stripePaymentMethodId: string; // "pm_1234567890abcdef"
  customerId: string;
  last4: string; // OK: non-sensitive display data
  brand: string; // "visa", "mastercard" - safe to store
  expiryMonth: number; // ⚠️ borderline - see below
  expiryYear: number;  // ⚠️ borderline - see below
  createdAt: Date;
}

// ❌ WRONG: Never store these
interface BadPaymentMethod {
  cardNumber: string; // NEVER
  cvv: string; // NEVER
  fullName: string; // unless needed for reconciliation
  billingAddress: string; // unless needed for AVS
}

The expiry month and year are a gray area. They're not sensitive in PCI terms (they're printed on the card), but storing them creates a liability: if your database is breached, attackers have everything except the CVV. Best practice: don't store them. Retrieve them from Stripe when needed for display or renewal logic.

When you charge the customer, you reference that payment method ID:

typescript

async function chargeCustomer(
  customerId: string,
  paymentMethodId: string,
  amountInCents: number
) {
  const charge = await stripe.charges.create({
    amount: amountInCents,
    currency: 'usd',
    payment_method: paymentMethodId, // Stripe ID, never raw card data
    customer: customerId,
    confirm: true,
  });
  
  // Store only the charge ID and outcome
  await db.payments.create({
    customerId,
    stripeChargeId: charge.id,
    amountInCents,
    status: charge.status,
    createdAt: new Date(),
  });
}

This approach gives you several benefits. Your database is worthless to attackers—they have IDs that only work with Stripe's systems and require API authentication. You reduce your PCI compliance scope dramatically. Stripe handles fraud, disputes, and token management.

Minimize personal data in payment flows

Personal data—names, emails, addresses, phone numbers—is often collected alongside payment data. The risk multiplies when you store them together.

Start by auditing what you actually need. A subscription renewal doesn't require the customer's full billing address. An invoice email doesn't require their phone number. Invoice reconciliation might need the last 4 digits of the card and cardholder name (for verification), but not the full card data.

Separate personal data from payment data at the schema level:

typescript

// ✅ CORRECT: Separate tables with minimal cross-reference
interface CustomerProfile {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  phone: string;
  // Personal data lives here, with its own access controls
}

interface BillingInfo {
  id: string;
  customerId: string; // Foreign key, but minimal personal data
  streetAddress: string;
  city: string;
  state: string;
  postalCode: string;
  country: string;
  // Billing info separate from payment instruments
}

interface PaymentMethod {
  id: string;
  customerId: string;
  stripePaymentMethodId: string;
  last4: string;
  brand: string;
  // No personal data duplicated here
}

When you need to display a receipt or charge confirmation, construct the view dynamically from these separate records. Never denormalize sensitive data into a single "transactions" table.

Similarly, be cautious with logs and error tracking. If a Stripe API call fails, log the error and the charge ID—not the full payment method object. Scrub personal data from stack traces before they hit your error tracking service (Sentry, Rollbar, etc.).

typescript

// ✅ CORRECT: Log safely
import * as Sentry from "@sentry/node";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  beforeSend(event) {
    // Remove payment-related data from error context
    if (event.request?.headers) {
      delete event.request.headers['authorization'];
    }
    if (event.contexts?.charge) {
      delete event.contexts.charge.paymentMethodId;
    }
    return event;
  },
});

try {
  await stripe.charges.create(chargeParams);
} catch (error) {
  Sentry.captureException(error, {
    contexts: {
      charge: { chargeId: chargeParams.idempotency_key },
      // Log the ID for tracking, not the method
    },
  });
}

Restrict access to payment data

Even within your organization, most engineers don't need access to payment data. A frontend developer building the checkout UI doesn't need read access to the payment methods table. A data analyst building dashboards doesn't need live transaction records.

Implement role-based access control (RBAC) in your application and database:

  • Payment service (internal API): Only this service reads and writes payment methods and charges. It's called by other services via controlled endpoints.
  • Billing service: Can read payment outcomes (success/failure, amount, date) for invoicing. Cannot read payment method IDs.
  • Reporting/Analytics: Can query aggregated payment metrics (revenue by period, churn rate). Cannot see individual transactions or customer IDs linked to payments.
  • Support team: Can look up transaction status and dispute history by charge ID. Cannot retrieve or modify payment method data.

At the database level, use view-based access control:

typescript

// PostgreSQL example: create restricted views
CREATE VIEW payment_summaries AS
SELECT
  id,
  customer_id,
  amount_cents,
  status,
  created_at
FROM payments
-- No stripe_payment_method_id, no sensitive data

CREATE VIEW payment_methods_internal AS
SELECT
  id,
  customer_id,
  stripe_payment_method_id,
  last4,
  brand
FROM payment_methods
-- Only accessible to payment service role

-- Grant access by role
GRANT SELECT ON payment_summaries TO analytics_role;
GRANT SELECT ON payment_methods_internal TO payment_service_role;
REVOKE SELECT ON payment_methods FROM public_role;

For external integrations (accounting software, analytics platforms), use API keys with scoped permissions. A tool that needs to reconcile invoices doesn't need an API key that grants access to the payment methods endpoint.

Finally, audit access. Log who queried payment data, when, and what they retrieved. Review those logs quarterly.

Follow data retention policies

Every byte of payment data should have an expiration date. The longer you keep it, the larger your attack surface. PCI DSS requires you to keep payment card data for dispute and chargeback handling—typically 90 days post-transaction. Beyond that, there's no business reason to retain raw payment method data.

Build a retention strategy:

Data type Retention window Reason
Payment method tokens (Stripe IDs) Until customer deletes or account closes Needed for recurring charges and future transactions
Raw card data, CVV, full details Never store PCI compliance reduces scope
Charge/transaction records (ID, amount, outcome) 7 years Tax, audit, and dispute resolution requirements
Dispute/chargeback details 3-5 years Regulatory and card network requirements
Logs (payment API calls, errors) 30-90 days Debugging and security monitoring
Customer personal data linked to payments Account lifetime + 1 year Tax, disputes, then GDPR "right to be forgotten"

Implement automated deletion jobs. Don't rely on manual processes:

typescript

// Scheduled job: delete old payment logs
import { CronJob } from 'cron';

const deleteOldLogs = new CronJob('0 2 * * *', async () => {
  const ninetyDaysAgo = new Date();
  ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
  
  const result = await db.paymentLogs.deleteMany({
    createdAt: { $lt: ninetyDaysAgo },
  });
  
  console.log(`Deleted ${result.deletedCount} old payment logs`);
});

deleteOldLogs.start();

When a customer requests data deletion (GDPR, CCPA), delete personal data immediately. For payment methods tied to that account, either delete the token (which will fail future charges) or detach it from the customer record. For transaction history, anonymize it: remove customer identifiers but keep the charge ID and outcome for tax/audit purposes.

Common mistakes

  • Storing card data "for convenience". Some teams cache the full payment method response from Stripe thinking it speeds up the checkout flow. It doesn't—and it creates massive compliance risk. Always tokenize on the client side.
  • Logging the full Stripe API response. Stripe's responses include metadata you stored, which might contain PII. Sanitize logs before they're written.
  • Assuming your payment processor handles all compliance. Stripe is PCI compliant, but you're not. Your database, your API, your logs—those are your responsibility. Compliance is a shared model.
  • Never testing deletion workflows. Retention policies only work if the deletion actually happens. Test that old records are purged, and that the deletion doesn't break reconciliation or reporting.
  • Mixing test and production keys without isolation. A compromised test API key can still reveal production data. Keep them in separate vaults, rotate them regularly, and use different database credentials for dev/staging/prod.

Key takeaways

  • Store only payment provider IDs (Stripe payment method IDs, charge IDs). Never store raw card data, CVV, or full expiry dates.
  • Separate personal data, billing data, and payment data at the schema and access-control levels. Minimize what's stored together.
  • Use role-based access control and views to restrict payment data access by function. Log all access for audit trails.
  • Define retention windows for every data type and automate deletion. Personal data expires when the account closes; transaction records last 7 years for tax purposes.
  • Sanitize logs and error tracking before they leave your system. Scrub payment-related context from external services.
  • Test your deletion and anonymization workflows. Compliance only works in practice, not in theory.
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