PCI Compliance Testing Checklist for Developers
PCI DSS compliance testing is about verifying that cardholder data is handled securely — not stored when it shouldn't be, encrypted when transmitted, and accessible only to authorized systems. Most developers don't need to handle raw card data at all if they use Stripe or Braintree's hosted payment fields. This checklist covers what to test regardless of your payment integration approach.
Key Takeaways
Most developers can avoid PCI scope entirely by using hosted payment fields. Stripe Elements, Stripe Checkout, and Braintree Drop-in UI handle card data inside iframes served from the payment provider's domain. Your servers never see raw card numbers — PCI scope stays minimal (SAQ A or SAQ A-EP).
Test that card data is never logged. The most common PCI violation is accidentally logging card numbers in application logs. Test this by searching logs after a payment for the card number pattern \b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b.
Verify TLS version and cipher suite. PCI DSS 4.0 requires TLS 1.2 minimum; TLS 1.3 is recommended. Test with openssl s_client or ssl-checker tools. Reject connections on TLS 1.0 and 1.1.
Test CVV is never stored. PCI prohibits storing CVV after authorization. Test your payment flow and verify the database contains no CVV values post-transaction.
Test access controls for payment data. Only the payment processing service should have access to Stripe API keys. Test that your staging environment can't reach production payment APIs and that API keys aren't exposed in frontend code.
Understanding PCI Scope
PCI DSS (Payment Card Industry Data Security Standard) applies to any system that stores, processes, or transmits cardholder data. The scope of testing depends on how you handle card data:
SAQ A (lowest scope): Fully outsourced card processing. You use Stripe Checkout (hosted page) or embedded iframes (Stripe Elements, Braintree Drop-in). Your servers never receive raw card numbers. Minimal testing required.
SAQ A-EP: You use JavaScript-based iframes (like Stripe.js Elements) on your own page. Card data goes directly from browser to payment provider — your server never sees it.
SAQ D (highest scope): Your server receives, stores, or transmits raw card data. Requires the most extensive testing and a full Qualified Security Assessor (QSA) audit.
Most modern applications fall into SAQ A or SAQ A-EP by using hosted payment fields.
Checklist 1: Card Data Handling
Verify no card data reaches your servers
# Run a test payment and search your application logs for card number patterns
grep -E <span class="hljs-string">'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b' /var/log/app/*.<span class="hljs-built_in">log
<span class="hljs-comment"># If this returns any results, you have a PCI violation
<span class="hljs-comment"># Card numbers should never appear in logsTest that CVV is not stored
After a test transaction, inspect your database for CVV values:
def test_cvv_not_stored_after_transaction():
# Run a test payment
make_test_payment(card_number="4242424242424242", cvv="123")
# Query database for CVV patterns
cursor.execute("""
SELECT COUNT(*) FROM payment_logs
WHERE data::text ~ '\b[0-9]{3,4}\b'
AND data::text LIKE '%cvv%'
""")
# There should be no CVV stored
# A positive result here means a PCI violation
# Also check in raw transaction objects
cursor.execute("""
SELECT COUNT(*) FROM transactions
WHERE card_verification_value IS NOT NULL
""")
assert cursor.fetchone()[0] == 0, "CVV is being stored — PCI violation"Verify truncated PAN storage
If you store card numbers, only the last 4 digits should be retained:
def test_card_number_is_truncated():
make_test_payment(card_number="4242424242424242")
transaction = get_last_transaction()
# Should only have last 4 digits
assert transaction.card_last_four == "4242"
# Should NOT contain full card number
assert "4242424242424242" not in transaction.card_display
# Verify the pattern: only last 4 digits are visible
import re
assert re.match(r'^\*{4} \*{4} \*{4} \d{4}$', transaction.card_display)Checklist 2: TLS and Encryption
Test TLS version
# Check TLS version support on your payment endpoint
openssl s_client -connect your-app.com:443 -tls1 2>&1 <span class="hljs-pipe">| grep <span class="hljs-string">"Secure Renegotiation"
<span class="hljs-comment"># Should show error for TLS 1.0
openssl s_client -connect your-app.com:443 -tls1_1 2>&1 <span class="hljs-pipe">| grep <span class="hljs-string">"Secure Renegotiation"
<span class="hljs-comment"># Should show error for TLS 1.1
openssl s_client -connect your-app.com:443 -tls1_2 2>&1 <span class="hljs-pipe">| grep <span class="hljs-string">"Cipher"
<span class="hljs-comment"># Should succeed with TLS 1.2
<span class="hljs-comment"># With testssl.sh (comprehensive)
./testssl.sh --protocols your-app.comAutomated TLS test in pytest
import ssl
import socket
def test_tls_1_0_rejected():
"""PCI DSS 4.0 requires TLS 1.2 minimum. TLS 1.0 must be rejected."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.maximum_version = ssl.TLSVersion.TLSv1
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with pytest.raises(ssl.SSLError):
with socket.create_connection(("your-app.com", 443)) as sock:
with context.wrap_socket(sock) as ssock:
pass # Should not reach here
def test_tls_1_2_accepted():
"""TLS 1.2 should work."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.maximum_version = ssl.TLSVersion.TLSv1_2
with socket.create_connection(("your-app.com", 443)) as sock:
with context.wrap_socket(sock, server_hostname="your-app.com") as ssock:
assert ssock.version() == "TLSv1.2"Test HTTPS redirect
def test_http_redirects_to_https():
import requests
response = requests.get("http://your-app.com/checkout", allow_redirects=False)
assert response.status_code in (301, 302)
assert response.headers["Location"].startswith("https://")
def test_hsts_header_present():
response = requests.get("https://your-app.com")
hsts = response.headers.get("Strict-Transport-Security", "")
assert "max-age" in hsts
# PCI recommends at least 1 year
max_age = int(hsts.split("max-age=")[1].split(";")[0].strip())
assert max_age >= 31536000 # 1 year in secondsChecklist 3: API Key Security
Verify API keys are not exposed in frontend code
def test_stripe_secret_key_not_in_frontend():
"""Secret keys (sk_live_, sk_test_) must never appear in frontend code."""
import requests
# Fetch your compiled JavaScript bundle
response = requests.get("https://your-app.com/static/js/main.js")
content = response.text
# Check for secret key patterns
assert "sk_live_" not in content, "Stripe live secret key found in frontend!"
assert "sk_test_" not in content, "Stripe test secret key found in frontend!"
# Publishable keys are OK in frontend (pk_live_, pk_test_)
def test_env_vars_not_in_client_bundle():
"""Sensitive env vars should not be bundled into client-side code."""
response = requests.get("https://your-app.com/static/js/main.js")
content = response.text
# These should never appear in client bundles
sensitive_patterns = [
"DATABASE_URL",
"STRIPE_SECRET",
"WEBHOOK_SECRET",
"API_KEY",
]
for pattern in sensitive_patterns:
assert pattern not in content, f"Found '{pattern}' in client bundle"Test API key scoping
def test_payment_api_key_not_accessible_from_worker():
"""Stripe keys should only be accessible to the payment service."""
# Test that worker processes (image processing, email, etc.)
# cannot reach payment API endpoints
# This depends on your architecture — network policies, IAM, etc.
# At minimum, verify the env var isn't set in non-payment services
import subprocess
result = subprocess.run(
["kubectl", "exec", "-n", "production", "worker-pod-xxx", "--",
"printenv", "STRIPE_SECRET_KEY"],
capture_output=True
)
assert result.returncode != 0, "STRIPE_SECRET_KEY accessible in worker pod"Checklist 4: Access Controls
Test payment admin access
def test_payment_data_requires_authentication(client):
"""Payment history should not be accessible without authentication."""
response = client.get("/api/payments")
assert response.status_code == 401
def test_payment_data_requires_authorization(client, user_session, admin_session):
"""Users should only see their own payment data."""
# Regular user can see their own payments
response = client.get("/api/payments", headers=user_session)
assert response.status_code == 200
# Regular user cannot see other users' payments
response = client.get("/api/payments/other-user-id", headers=user_session)
assert response.status_code in (403, 404)
def test_webhook_endpoint_requires_signature(client):
"""Webhook endpoint must reject requests without valid Stripe signature."""
response = client.post(
"/webhooks/stripe",
json={"type": "payment_intent.succeeded"},
# No stripe-signature header
)
assert response.status_code == 400Checklist 5: Logging and Monitoring
Verify payment events are audited
def test_payment_events_are_logged():
"""All payment events must be logged for PCI audit trail."""
make_test_payment(amount=5000, card="4242424242424242424242")
# Check audit log contains the event
audit_entries = get_audit_log(event_type="payment_processed")
last_entry = audit_entries[-1]
assert last_entry["event_type"] == "payment_processed"
assert last_entry["amount"] == 5000
assert last_entry["timestamp"] is not None
assert last_entry["user_id"] is not None
# Verify sensitive data is NOT in the audit log
assert "card_number" not in last_entry
assert "cvv" not in last_entry
assert "4242" not in str(last_entry) # No full card numbers
def test_failed_payment_attempts_logged():
"""Failed payment attempts must also be logged for fraud detection."""
make_test_payment(amount=5000, card="4000000000000002") # Declined card
audit_entries = get_audit_log(event_type="payment_failed")
assert len(audit_entries) > 0
last_entry = audit_entries[-1]
assert last_entry["failure_reason"] is not NoneChecklist 6: Network Security
Test payment service isolation
# If running in Kubernetes: verify network policies
kubectl get networkpolicy -n production <span class="hljs-pipe">| grep payment
<span class="hljs-comment"># Payment service should only accept traffic from:
<span class="hljs-comment"># - API gateway / load balancer
<span class="hljs-comment"># - Internal services that need to initiate payments
<span class="hljs-comment"># Payment service should NOT be reachable from:
<span class="hljs-comment"># - Worker pods
<span class="hljs-comment"># - Analytics services
<span class="hljs-comment"># - Any external source except Stripe webhook IPsStripe webhook IP allowlisting
STRIPE_WEBHOOK_IPS = [
# Stripe's published webhook IP ranges
"3.18.12.63",
"3.130.192.231",
"13.235.14.237",
# ... full list from Stripe's documentation
]
def test_webhook_from_non_stripe_ip_rejected(client):
"""Webhooks from non-Stripe IPs should be rejected."""
payload, sig = make_stripe_event("payment_intent.succeeded", {}, WEBHOOK_SECRET)
response = client.post(
"/webhooks/stripe",
content=payload,
headers={
"stripe-signature": sig,
"X-Forwarded-For": "1.2.3.4", # Not a Stripe IP
},
)
# Depending on your policy, this may return 403 or still accept
# (Stripe recommends signature verification, not IP allowlisting)
# Document your policy and test it explicitlyRunning PCI Scans
For SAQ A-EP and above, PCI requires quarterly external vulnerability scans by an Approved Scanning Vendor (ASV).
For internal testing, use these tools:
# OWASP ZAP for web application scanning
docker run -t owasp/zap2docker-stable zap-baseline.py -t https://your-app.com
<span class="hljs-comment"># nmap for port scanning (ensure no unexpected ports are open)
nmap -sV -p- your-app.com
<span class="hljs-comment"># SSL/TLS scanning
docker run --<span class="hljs-built_in">rm drwetter/testssl.sh your-app.comSummary Checklist
| Item | Test method | Pass criteria |
|---|---|---|
| No card data in logs | grep for card patterns after test payment | Zero matches |
| CVV not stored | Database query after transaction | No CVV fields |
| PAN truncated | Check stored card display | Last 4 only |
| TLS 1.2+ enforced | openssl s_client | TLS 1.0/1.1 rejected |
| HTTPS redirect | HTTP → HTTPS test | 301/302 redirect |
| HSTS header | Response header check | max-age ≥ 31536000 |
| Secret key not in frontend | JS bundle grep | No sk_live_/sk_test_ |
| Auth required for payment data | Unauthenticated request | 401 response |
| Webhook signature verified | Request without signature | 400 response |
| Payment events audited | Audit log query | Events logged without sensitive data |
Run this checklist before every major payment feature release and as part of your quarterly security review. PCI compliance is not a one-time certification — it requires ongoing verification.