PCI Compliance Testing Checklist for Developers

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 logs

Test 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.com

Automated 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 seconds

Checklist 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 == 400

Checklist 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 None

Checklist 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 IPs

Stripe 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 explicitly

Running 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.com

Summary 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.

Read more