Payment Security Best Practices
Security is critical when handling payments. This guide covers best practices for keeping your UIGen payment integration secure and PCI compliant.
API Key Management
✅ DO: Use Environment Variables
Always store API keys in environment variables, never in code:
# Good ✅
x-uigen-payments:
providers:
- provider: stripe
apiKey: ${STRIPE_SECRET_KEY}
publishableKey: ${STRIPE_PUBLISHABLE_KEY}
# Bad ❌ - Never do this!
x-uigen-payments:
providers:
- provider: stripe
apiKey: "sk_live_abc123..."
publishableKey: "pk_live_xyz789..."
✅ DO: Use Different Keys for Test and Production
Maintain separate API keys for development and production:
# Development (.env.development)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
# Production (.env.production)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
✅ DO: Rotate Keys Regularly
Rotate your API keys periodically (every 90 days recommended):
- Generate new keys in provider dashboard
- Update environment variables
- Deploy with new keys
- Revoke old keys after verification
❌ DON'T: Commit Keys to Version Control
Add .env to your .gitignore:
# Environment variables
.env
.env.local
.env.*.local
# API keys
*.key
*.pem
Provide a .env.example template instead:
# .env.example
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
Webhook Security
✅ DO: Verify Webhook Signatures
Always verify webhook signatures to ensure requests are from your payment provider:
# Stripe example
import stripe
def verify_stripe_webhook(payload, signature, secret):
try:
event = stripe.Webhook.construct_event(
payload, signature, secret
)
return event
except stripe.error.SignatureVerificationError:
# Invalid signature
raise HTTPException(status_code=400, detail="Invalid signature")
✅ DO: Use HTTPS for Webhooks
Always use HTTPS for webhook endpoints in production:
# Good ✅
webhookUrl: https://api.example.com/webhooks/stripe
# Bad ❌
webhookUrl: http://api.example.com/webhooks/stripe
✅ DO: Handle Idempotency
Webhooks may be sent multiple times. Use idempotency keys to prevent duplicate processing:
processed_events = set()
async def handle_webhook(event):
event_id = event['id']
# Check if already processed
if event_id in processed_events:
return {"status": "already_processed"}
# Process event
await process_event(event)
# Mark as processed
processed_events.add(event_id)
return {"status": "success"}
❌ DON'T: Trust Webhook Data Without Verification
Never process webhook data without signature verification:
# Bad ❌ - Don't do this!
@app.post("/webhooks/stripe")
async def webhook(data: dict):
# Processing data without verification
await grant_access(data['customer_id'])
PCI Compliance
✅ DO: Use Provider-Hosted Checkout
UIGen uses provider-hosted checkout pages, which means:
- Card data never touches your servers
- You don't need PCI certification
- Provider handles all card security
// UIGen automatically redirects to provider checkout
<PaymentButton productId="pro-monthly">
Subscribe
</PaymentButton>
❌ DON'T: Store Card Data
Never store credit card numbers, CVV codes, or full card data:
# Bad ❌ - Never do this!
user.card_number = "4242424242424242"
user.cvv = "123"
user.save()
Instead, store only:
- Customer ID from payment provider
- Last 4 digits (if needed for display)
- Card brand (Visa, Mastercard, etc.)
# Good ✅
user.stripe_customer_id = "cus_abc123"
user.card_last4 = "4242"
user.card_brand = "visa"
user.save()
✅ DO: Use Tokenization
If you need to collect card data, use provider SDKs for tokenization:
// Stripe.js example (client-side)
const stripe = Stripe('pk_test_...');
const {token} = await stripe.createToken(cardElement);
// Send only the token to your server
await fetch('/api/save-card', {
method: 'POST',
body: JSON.stringify({token: token.id})
});
HTTPS and TLS
✅ DO: Enforce HTTPS in Production
UIGen validates that payment URLs use HTTPS (except localhost):
# Good ✅
successUrl: https://app.example.com/payment/success
cancelUrl: https://app.example.com/payment/cancel
# Bad ❌ (will fail validation in production)
successUrl: http://app.example.com/payment/success
✅ DO: Use TLS 1.2 or Higher
Ensure your server supports TLS 1.2 or higher:
# Nginx example
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
Access Control
✅ DO: Verify User Ownership
Always verify that users can only access their own payment data:
async def get_subscription(subscription_id: str, current_user: User):
subscription = await db.get_subscription(subscription_id)
# Verify ownership
if subscription.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
return subscription
✅ DO: Implement Rate Limiting
Protect payment endpoints from abuse:
from fastapi_limiter import FastAPILimiter
from fastapi_limiter.depends import RateLimiter
@app.post("/api/checkout")
@limiter.limit("5/minute") # Max 5 checkout attempts per minute
async def create_checkout(request: Request):
# Create checkout session
pass
✅ DO: Log Payment Events
Keep detailed logs of all payment events:
import logging
logger = logging.getLogger(__name__)
async def handle_payment_succeeded(invoice):
logger.info(
"Payment succeeded",
extra={
"customer_id": invoice['customer'],
"amount": invoice['amount_paid'],
"invoice_id": invoice['id'],
"timestamp": datetime.now()
}
)
Error Handling
✅ DO: Handle Errors Gracefully
Don't expose sensitive information in error messages:
# Good ✅
try:
await process_payment(customer_id)
except Exception as e:
logger.error(f"Payment processing failed: {e}")
raise HTTPException(
status_code=500,
detail="Payment processing failed. Please try again."
)
# Bad ❌
try:
await process_payment(customer_id)
except Exception as e:
# Exposing internal details
raise HTTPException(
status_code=500,
detail=f"Database error: {str(e)}"
)
✅ DO: Validate Input
Validate all payment-related input:
from pydantic import BaseModel, validator
class CheckoutRequest(BaseModel):
product_id: str
customer_id: str
@validator('product_id')
def validate_product_id(cls, v):
if not v.startswith('price_'):
raise ValueError('Invalid product ID')
return v
Monitoring and Alerts
✅ DO: Monitor Failed Payments
Set up alerts for failed payments:
async def handle_payment_failed(invoice):
customer_id = invoice['customer']
amount = invoice['amount_due']
# Log failure
logger.error(f"Payment failed for customer {customer_id}")
# Send alert
await send_alert(
f"Payment failed: ${amount/100} for customer {customer_id}"
)
# Notify customer
await send_email(customer_id, "payment_failed")
✅ DO: Track Suspicious Activity
Monitor for unusual patterns:
async def check_suspicious_activity(customer_id: str):
# Check for multiple failed attempts
failed_attempts = await db.count_failed_payments(
customer_id,
since=datetime.now() - timedelta(hours=1)
)
if failed_attempts > 3:
logger.warning(f"Suspicious activity: {customer_id}")
await flag_for_review(customer_id)
✅ DO: Set Up Uptime Monitoring
Monitor your webhook endpoints:
# Example: UptimeRobot configuration
monitors:
- name: Stripe Webhook
url: https://api.example.com/webhooks/stripe
type: HTTP
interval: 5 # minutes
alert_contacts:
- email: alerts@example.com
Data Protection
✅ DO: Encrypt Sensitive Data
Encrypt sensitive payment data at rest:
from cryptography.fernet import Fernet
# Generate key (store securely)
key = Fernet.generate_key()
cipher = Fernet(key)
# Encrypt customer ID
encrypted_id = cipher.encrypt(customer_id.encode())
# Decrypt when needed
decrypted_id = cipher.decrypt(encrypted_id).decode()
✅ DO: Implement Data Retention Policies
Delete old payment data according to your retention policy:
async def cleanup_old_payment_data():
# Delete payment records older than 7 years
cutoff_date = datetime.now() - timedelta(days=7*365)
await db.delete_payments(
where={"created_at": {"$lt": cutoff_date}}
)
✅ DO: Comply with GDPR/CCPA
Implement data deletion for user requests:
async def delete_user_payment_data(user_id: str):
# Delete from database
await db.delete_user_payments(user_id)
# Delete from payment provider
customer = await get_stripe_customer(user_id)
await stripe.Customer.delete(customer.id)
logger.info(f"Deleted payment data for user {user_id}")
Testing Security
✅ DO: Test Webhook Signature Verification
def test_webhook_signature_verification():
# Test with invalid signature
with pytest.raises(HTTPException) as exc:
verify_webhook(payload, "invalid_signature", secret)
assert exc.value.status_code == 400
✅ DO: Test Access Control
def test_subscription_access_control():
# User A tries to access User B's subscription
response = client.get(
f"/api/subscriptions/{user_b_subscription_id}",
headers={"Authorization": f"Bearer {user_a_token}"}
)
assert response.status_code == 403
✅ DO: Perform Security Audits
Regular security audits:
- Review API key usage
- Check for exposed secrets
- Verify HTTPS enforcement
- Test webhook security
- Review access controls
Incident Response
✅ DO: Have an Incident Response Plan
- Detect - Monitor for security incidents
- Contain - Revoke compromised keys immediately
- Investigate - Determine scope of breach
- Notify - Inform affected users if required
- Recover - Restore normal operations
- Learn - Update security practices
✅ DO: Revoke Compromised Keys Immediately
If API keys are compromised:
- Generate new keys in provider dashboard
- Update environment variables
- Deploy immediately
- Revoke old keys
- Review logs for suspicious activity
- Notify users if necessary
Compliance Checklist
- API keys stored in environment variables
- Webhook signatures verified
- HTTPS enforced in production
- No card data stored
- Access control implemented
- Rate limiting enabled
- Error handling doesn't expose secrets
- Logging implemented
- Monitoring and alerts set up
- Data encryption at rest
- Data retention policy implemented
- GDPR/CCPA compliance
- Security tests written
- Incident response plan documented