Paystack Security, Keys and PCI Compliance: Complete Guide
Paystack gives you four API keys: public test, secret test, public live, and secret live. Public keys go in frontend code and can only initialize transactions. Secret keys stay on your server and must never be exposed. Paystack handles PCI DSS compliance for card data, but you are responsible for securing your own keys, verifying amounts server-side, and validating webhook signatures.
Security in Paystack Integrations
Payment security is not optional. A single vulnerability in your Paystack integration can cost you money, customer trust, and regulatory trouble. The good news: Paystack handles the hardest part (storing and processing card numbers). The bad news: you still have plenty of ways to get it wrong.
This guide covers every security concern a developer faces when building on Paystack. API key management, PCI compliance boundaries, common attack vectors, and the defensive code patterns that stop them. If you are building a production Paystack integration in Africa, read this before you go live.
For the full integration picture, see the complete Paystack engineering guide.
API Key Types: Public, Secret, Test, and Live
Paystack gives you four keys through the dashboard Settings page. Here is what each one does:
- Public Test Key (
pk_test_...): Used in your frontend code during development. Can only initialize transactions. Cannot verify, charge, transfer, or read account data. - Secret Test Key (
sk_test_...): Used on your server during development. Can do everything: verify transactions, create transfers, manage subscriptions, read customer data. Works only against the test environment. No real money moves. - Public Live Key (
pk_live_...): Same permissions as the public test key, but against the live environment. Goes in your production frontend code. - Secret Live Key (
sk_live_...): Same permissions as the secret test key, but against the live environment. Real money. This is the key you must protect above all else.
The naming convention tells you two things: the key type (pk for public, sk for secret) and the environment (test or live). If you see sk_live_ anywhere in client-side code, you have a critical security issue.
Here is a quick reference table:
Key Prefix | Environment | Where It Goes | What It Can Do
--------------+-------------+--------------------+---------------------------
pk_test_... | Test | Frontend (dev) | Initialize transactions
sk_test_... | Test | Server (dev) | Everything (no real money)
pk_live_... | Live | Frontend (prod) | Initialize transactions
sk_live_... | Live | Server (prod) | Everything (real money)
The public key is designed to be visible. It is embedded in your HTML or JavaScript, and that is fine. An attacker who has your public key can initialize a transaction on your behalf, but they cannot verify it, extract customer data, or move money. The payment still goes through Paystack's secure checkout, and you still verify server-side.
The secret key is different. With your secret key, an attacker can:
- Verify (and therefore confirm) transactions they control
- Create transfers to send money out of your account
- Read all your customer data, transaction history, and settlement information
- Create and manage subscriptions
- Issue refunds
Key Rotation
Key rotation means generating new API keys and deactivating the old ones. You should rotate keys when:
- A team member with access to the keys leaves the company
- You suspect a key has been exposed (committed to a public repo, logged to a monitoring service, sent in a chat message)
- Your security policy requires periodic rotation (common in fintech companies)
- You are moving from one hosting provider to another
To rotate your keys:
- Log in to your Paystack dashboard
- Go to Settings, then API Keys & Webhooks
- Click "Generate New Keys" (or the equivalent button for your key type)
- Copy the new keys immediately. Paystack will not show the secret key again after you leave the page.
- Update your server environment variables with the new secret key
- Update your frontend code with the new public key
- Deploy the changes
- Verify that payments still work in both test and live mode
The old keys become invalid immediately after rotation. This means you need to deploy your updated keys before (or at the exact moment of) rotation. For zero-downtime rotation:
// Support both old and new keys during rotation
// This pattern gives you a few minutes to rotate
// without dropping any webhook verifications
function verifyWebhookSignature(body, signature) {
const keys = [
process.env.PAYSTACK_SECRET_KEY, // Current key
process.env.PAYSTACK_SECRET_KEY_OLD, // Previous key (remove after rotation)
].filter(Boolean);
for (const key of keys) {
const hash = crypto
.createHmac('sha512', key)
.update(body)
.digest('hex');
if (hash === signature) {
return true;
}
}
return false;
}
After confirming everything works with the new key, remove the old key from your environment and clean up the fallback code.
If your secret key has leaked: Rotate immediately. Do not wait until morning. Do not wait for a deployment window. A leaked live secret key is a financial emergency. Generate new keys, deploy, and then investigate how the leak happened.
Storing Keys Properly
The rule is simple: secret keys belong in environment variables, not in code. Here is a checklist:
# .env file (local development only - NEVER commit this file)
PAYSTACK_SECRET_KEY=sk_test_your_key_here
PAYSTACK_PUBLIC_KEY=pk_test_your_key_here
# .gitignore - add this line
.env
.env.local
.env.production
For production deployments, use your hosting provider's secrets management:
- Vercel: Project Settings, then Environment Variables. Set
PAYSTACK_SECRET_KEYfor the Production environment. - Railway: Variables tab in your service settings.
- Render: Environment section in your service dashboard.
- AWS: Systems Manager Parameter Store (SecureString) or Secrets Manager.
- Docker: Pass as environment variables at runtime, not in the Dockerfile.
Common mistakes that expose keys:
- Committing .env to git. Even if you delete it later, the key is in your git history. Anyone who clones the repo can find it with
git log. Usegit-filter-repoto purge sensitive files from history if this happens. - Hardcoding keys in source files. Never do this:
// WRONG - key is in your source code const PAYSTACK_KEY = 'sk_live_abc123xyz'; // RIGHT - read from environment const PAYSTACK_KEY = process.env.PAYSTACK_SECRET_KEY; - Logging full request headers. If your server logs include the
Authorizationheader, your secret key appears in plain text in your log files. Redact it. - Sending keys over Slack or email. Use a secrets manager or encrypted channel instead. If you must share a test key, rotate it afterward.
- Using the secret key in frontend code. This happens more often than you would think. A developer copies the wrong key, and now every visitor to your site can see your secret key in the browser's Network tab.
Add a startup check to catch missing keys early:
// Server startup check
function validateEnvironment() {
const required = ['PAYSTACK_SECRET_KEY'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error(
`Missing required environment variables: ${missing.join(', ')}`
);
process.exit(1);
}
// Warn if using test keys in production
if (
process.env.NODE_ENV === 'production' &&
process.env.PAYSTACK_SECRET_KEY.startsWith('sk_test_')
) {
console.warn('WARNING: Using test secret key in production environment');
}
}
validateEnvironment();
PCI DSS Scope with Paystack
PCI DSS (Payment Card Industry Data Security Standard) is a set of security requirements for any organization that handles credit card data. If you store, process, or transmit cardholder data (card numbers, CVVs, expiry dates), you must comply with PCI DSS. The compliance process is expensive and complex.
Paystack is PCI DSS Level 1 certified, the highest level of compliance. When you use Paystack's checkout (Popup, Redirect, or Inline.js), card data goes directly from the customer's browser to Paystack's servers. Your server never sees a card number.
This means your PCI scope is minimal. You fall under SAQ A (Self-Assessment Questionnaire A), the simplest PCI compliance category. SAQ A applies when:
- All cardholder data processing is done by a PCI-compliant third party (Paystack)
- Your website does not receive cardholder data, even transiently
- You do not store, process, or transmit cardholder data on your systems
What you still must do under SAQ A:
- Ensure your checkout page is served over HTTPS
- Protect your Paystack API keys
- Do not add any JavaScript to your checkout page that could intercept card data (no keyloggers, no form scrapers)
- Maintain a basic security policy for your organization
What you must never do:
- Never ask customers to type card numbers into your own form fields. Always use Paystack's Popup or Redirect checkout. Even if you "just pass the card number to Paystack's API," the moment that card number touches your server, you are in PCI scope and need SAQ D (the full, expensive questionnaire).
- Never log card numbers. Not in error logs, not in request bodies, not in analytics. If a card number appears in your logs, you have a PCI violation.
- Never store authorization codes alongside identifying information in an unsecured database. Authorization codes (used for recurring charges) are sensitive, though not as sensitive as full card numbers.
The Charge API is a special case. If you use Paystack's Charge API with raw card details (card number, CVV, expiry), you are processing cardholder data on your server. This puts you in full PCI DSS scope. For almost every application, the standard Popup or Redirect checkout is the right choice. The Charge API with card details is for large organizations with existing PCI compliance programs.
Price Tampering Attacks
Price tampering is the most common attack on payment integrations. It is also the easiest to prevent. Here is how it works:
- Your frontend sends a request to your server to initialize a Paystack transaction for NGN 50,000.
- An attacker intercepts the request (using browser DevTools or a proxy like Burp Suite) and changes the amount to NGN 100.
- Your server passes the tampered amount to Paystack's Initialize Transaction endpoint.
- The customer (attacker) pays NGN 100.
- Paystack sends a
charge.successwebhook. The transaction status is "success." - If your webhook handler only checks the status and not the amount, you grant NGN 50,000 worth of value for a NGN 100 payment.
The fix: never accept the amount from the client. Your server must determine the expected amount from your database:
// WRONG - amount comes from the client
app.post('/api/initialize-payment', async (req, res) => {
const { amount, email } = req.body; // Attacker controls this
const response = await paystack.initializeTransaction({ amount, email });
res.json(response);
});
// RIGHT - amount comes from your database
app.post('/api/initialize-payment', async (req, res) => {
const { order_id } = req.body;
const order = await db.orders.findOne({ id: order_id, user_id: req.user.id });
if (!order) return res.status(404).json({ error: 'Order not found' });
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: order.amount_kobo, // From YOUR database
email: order.customer_email, // From YOUR database
reference: order.paystack_reference,
callback_url: `${process.env.BASE_URL}/payment/callback`,
}),
});
const data = await response.json();
res.json(data);
});
And in your verification handler, compare the verified amount against your order:
// In your webhook handler, after verifying the signature
const txn = webhookPayload.data;
const order = await db.orders.findOne({
paystack_reference: txn.reference,
});
if (txn.amount !== order.amount_kobo || txn.currency !== order.currency) {
console.error(`SECURITY: Amount/currency mismatch for ${txn.reference}`);
// Log the attempt, alert your team, but DO NOT grant value
return;
}
// Amount and currency match - safe to grant value
This two-layer defense (server-determined amount at initialization plus amount verification at webhook) makes price tampering impossible. The attacker cannot change the amount because the server controls it, and even if they found a way, the verification step would catch the mismatch.
Webhook Replay Attacks
A webhook replay attack works like this: an attacker captures a legitimate Paystack webhook payload (or constructs one that looks real) and sends it to your webhook endpoint. If your handler processes it without proper validation, the attacker can trigger actions like granting value or updating account balances.
Defense #1: Verify the webhook signature. Every Paystack webhook includes an x-paystack-signature header containing an HMAC-SHA512 hash of the request body, computed with your secret key. Recompute the hash and compare:
const crypto = require('crypto');
function isValidPaystackWebhook(req) {
const signature = req.headers['x-paystack-signature'];
if (!signature) {
return false;
}
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
return hash === signature;
}
// In your webhook handler
app.post('/webhooks/paystack', express.json(), (req, res) => {
if (!isValidPaystackWebhook(req)) {
console.warn('Invalid webhook signature received');
return res.status(401).send('Invalid signature');
}
// Signature is valid - process the event
// Return 200 immediately, then process asynchronously
res.sendStatus(200);
processWebhookEvent(req.body).catch(err => {
console.error('Webhook processing error:', err);
});
});
Warning: the raw body problem. If you use a body parser that modifies the raw request body (adds whitespace, reorders keys), your computed hash will not match Paystack's hash. Use the raw body for signature computation:
// Express.js - capture raw body for signature verification
app.post(
'/webhooks/paystack',
express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
},
}),
(req, res) => {
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(req.rawBody) // Use raw body, not JSON.stringify(req.body)
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
res.sendStatus(200);
processWebhookEvent(req.body).catch(console.error);
}
);
Defense #2: Idempotency. Even with signature verification, Paystack may send the same webhook multiple times (retries). Your handler must be idempotent. Use the transaction reference as a deduplication key:
async function processWebhookEvent(payload) {
const reference = payload.data.reference;
const eventType = payload.event;
// Atomic insert - fails silently if this event was already processed
const result = await db.query(
`INSERT INTO processed_webhooks (reference, event_type, payload, processed_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (reference, event_type) DO NOTHING
RETURNING id`,
[reference, eventType, JSON.stringify(payload)]
);
if (result.rows.length === 0) {
// Already processed this event for this reference
console.log(`Duplicate webhook skipped: ${eventType} for ${reference}`);
return;
}
// First time seeing this event - process it
if (eventType === 'charge.success') {
await handleChargeSuccess(payload.data);
}
}
Defense #3: Verify against Paystack. After receiving a webhook, call the Verify Transaction endpoint to confirm the transaction status directly with Paystack. A forged webhook might have a valid-looking payload, but the verification call will return the real status.
Rate Limiting Your Payment Endpoints
Your payment initialization endpoint is a target for abuse. Without rate limiting, an attacker can:
- Create thousands of pending transactions, cluttering your Paystack dashboard and database
- Trigger rate limits on Paystack's side, blocking legitimate customers
- Use your endpoint for card testing (trying stolen card numbers at small amounts to see which ones work)
Apply rate limits at three levels:
// Using express-rate-limit
const rateLimit = require('express-rate-limit');
// Level 1: Global rate limit - applies to all routes
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
message: { error: 'Too many requests. Try again later.' },
});
app.use(globalLimiter);
// Level 2: Payment-specific rate limit - stricter
const paymentLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 payment initializations per minute per IP
message: { error: 'Too many payment attempts. Wait a minute.' },
});
app.use('/api/initialize-payment', paymentLimiter);
// Level 3: Per-user rate limit (if authenticated)
const userPaymentLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 payment attempts per hour per user
keyGenerator: (req) => req.user?.id || req.ip,
message: { error: 'Too many payment attempts from your account.' },
});
app.use('/api/initialize-payment', userPaymentLimiter);
For your webhook endpoint, rate limiting is different. You do not want to rate-limit Paystack because that would cause you to miss legitimate webhooks. Instead, restrict the endpoint by IP if Paystack publishes a list of webhook source IPs, or rely solely on signature verification.
Card testing is a real concern for African businesses. Fraudsters use stolen card databases and script automated payments at small amounts (NGN 50, NGN 100) to test which cards are still active. Signs of card testing:
- Many small transactions from different cards but the same IP address
- Transactions with different customer emails but similar patterns
- High failure rates (most stolen cards are already cancelled)
- Activity outside your normal business hours
Report card testing to Paystack support immediately. They have tools to block these patterns at the gateway level.
Fraud Signals in Paystack Data
Paystack provides data in each transaction response that can help you detect fraud. Here are the signals to watch:
- Channel: If a customer who usually pays with bank transfer suddenly pays with a card, that might be worth flagging for high-value orders.
- IP address: The
ip_addressfield in the transaction data shows where the payment came from. A Nigerian customer paying from a European IP might indicate a VPN or a compromised account. - Card BIN: The first 6 digits of the card number (available in the
authorizationobject) identify the issuing bank and country. A card issued in the US used on a Nigerian store is higher risk. - Authorization reuse: The
authorizationobject includes areusableflag and anauthorization_code. Track how many different accounts use the same authorization. - Customer history: Paystack's
customerobject includes arisk_actionfield. You can also build your own risk scoring based on order history.
Build a simple risk scoring function:
function calculateRiskScore(transaction, orderHistory) {
let score = 0;
// New customer with no history
if (orderHistory.length === 0) {
score += 20;
}
// Large transaction amount
if (transaction.amount > 10000000) { // Over NGN 100,000
score += 15;
}
// Card issued in a different country than the business
const cardCountry = transaction.authorization?.country_code;
if (cardCountry && cardCountry !== 'NG') {
score += 25;
}
// Multiple failed attempts before success
const recentFailures = orderHistory.filter(
o => o.status === 'failed' &&
Date.now() - new Date(o.created_at) < 24 * 60 * 60 * 1000
);
if (recentFailures.length >= 3) {
score += 30;
}
// Late-night transaction (between 1 AM and 5 AM local time)
const hour = new Date(transaction.paid_at).getHours();
if (hour >= 1 && hour <= 5) {
score += 10;
}
return score;
}
// Usage in your webhook handler
const riskScore = calculateRiskScore(txnData, customerHistory);
if (riskScore >= 50) {
// Flag for manual review - do not auto-fulfill
await db.orders.updateOne(
{ paystack_reference: txnData.reference },
{ status: 'review_required', risk_score: riskScore }
);
await sendAlert('High-risk transaction flagged', { reference: txnData.reference, riskScore });
} else {
// Normal flow - grant value
await grantValue(txnData);
}
Do not block transactions based on risk scoring alone. Flag them for manual review. False positives (blocking legitimate customers) hurt your business more than the occasional fraud loss. Let a human make the final call on flagged orders.
Logging Without Secrets
You need logs to debug payment issues. But payment logs contain sensitive data. Here is what you must never log:
- Your Paystack secret key (often found in Authorization headers)
- Full card numbers (should never reach your server, but check your logs)
- CVVs and PINs
- Full webhook payloads in plain-text log files (they may contain customer PII)
What you should log:
- Transaction reference
- Transaction status
- Amount and currency
- Payment channel (card, bank, mobile money)
- Last 4 digits of card (if available from the authorization object)
- Customer email (hashed or truncated for privacy)
- Timestamps
- Error messages from Paystack API responses
Build a sanitization layer for your payment logs:
function sanitizeForLogging(data) {
const sanitized = { ...data };
// Remove secret key from any headers
if (sanitized.headers) {
sanitized.headers = { ...sanitized.headers };
if (sanitized.headers.Authorization) {
sanitized.headers.Authorization = 'Bearer sk_****';
}
if (sanitized.headers['x-paystack-signature']) {
sanitized.headers['x-paystack-signature'] = '[REDACTED]';
}
}
// Mask email
if (sanitized.customer?.email) {
const email = sanitized.customer.email;
const [local, domain] = email.split('@');
sanitized.customer = {
...sanitized.customer,
email: `${local.slice(0, 2)}***@${domain}`,
};
}
// Keep only last4 from authorization
if (sanitized.authorization) {
sanitized.authorization = {
last4: sanitized.authorization.last4,
channel: sanitized.authorization.channel,
card_type: sanitized.authorization.card_type,
bank: sanitized.authorization.bank,
country_code: sanitized.authorization.country_code,
// Remove: authorization_code, bin, exp_month, exp_year
};
}
return sanitized;
}
// Usage
function logTransaction(txn) {
const safe = sanitizeForLogging(txn);
console.log(JSON.stringify({
event: 'transaction_processed',
reference: safe.reference,
status: safe.status,
amount: safe.amount,
currency: safe.currency,
channel: safe.channel,
customer: safe.customer,
authorization: safe.authorization,
timestamp: new Date().toISOString(),
}));
}
For webhook payloads you want to store for audit purposes, encrypt them at rest. Use a separate database table with encryption:
CREATE TABLE webhook_audit_log (
id BIGSERIAL PRIMARY KEY,
reference VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload_enc BYTEA NOT NULL, -- Encrypted payload
iv BYTEA NOT NULL, -- Initialization vector
received_at TIMESTAMPTZ DEFAULT NOW(),
INDEX idx_audit_reference (reference)
);
This way, the full payload is available for debugging and compliance, but it cannot be read without the encryption key.
If you want to build secure, production-grade payment systems, the McTaba 26-week bootcamp covers security, testing, and deployment patterns that real African startups use. KES 120,000 for a complete career change into software engineering.
For more Paystack patterns, see the complete Paystack engineering guide.
Key Takeaways
- ✓Paystack provides four API keys: public test, secret test, public live, and secret live. Public keys initialize transactions on the frontend. Secret keys authorize server-side operations like verification, transfers, and refunds.
- ✓Never commit secret keys to version control. Store them in environment variables or a secrets manager. If a secret key leaks, rotate it immediately from the Paystack dashboard.
- ✓Paystack is PCI DSS Level 1 compliant and handles all card data. Your application never touches raw card numbers, so your PCI scope is minimal. But you must still protect your API keys, verify webhook signatures, and check amounts server-side.
- ✓Price tampering is the most common attack on Paystack integrations. An attacker modifies the amount in the frontend initialization request. Always verify the amount and currency from your server against your database before granting value.
- ✓Validate every webhook by computing the HMAC-SHA512 signature with your secret key and comparing it against the x-paystack-signature header. Without this check, an attacker can forge webhook payloads and trick your server into granting value.
Frequently Asked Questions
- Can someone steal money if they have my Paystack public key?
- No. The public key can only initialize transactions. It cannot verify transactions, create transfers, access customer data, or move money. It is designed to be visible in frontend code. Your secret key is the one you must protect.
- Do I need PCI DSS certification to use Paystack?
- No. If you use Paystack's standard checkout (Popup, Redirect, or Inline.js), card data goes directly to Paystack's PCI-certified servers. Your systems never touch card numbers. You fall under SAQ A, the simplest compliance category, which has minimal requirements.
- What should I do if my Paystack secret key is committed to a public GitHub repo?
- Rotate the key immediately from the Paystack dashboard. Do not wait. Then remove the key from your git history using git-filter-repo or BFG Repo-Cleaner. Update all your deployments with the new key. Review your transaction logs for any unauthorized activity during the exposure window.
- How do I prevent card testing on my Paystack integration?
- Apply rate limits to your payment initialization endpoint (5 requests per minute per IP is a reasonable start). Monitor for patterns like many small transactions from one IP or high failure rates. Report suspected card testing to Paystack support so they can apply gateway-level blocks.
- Should I verify the webhook signature AND call the Verify Transaction endpoint?
- Yes, do both. Signature verification proves the webhook came from Paystack. Transaction verification confirms the payment status and amount directly with Paystack's servers. Together, they protect against forged webhooks and ensure you grant value only for real, correctly-priced payments.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon