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.
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.
Paystack gives you four keys through the dashboard Settings page. Here is what each one does:
pk_test_...): Used in your frontend code during development. Can only initialize transactions. Cannot verify, charge, transfer, or read account data.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.pk_live_...): Same permissions as the public test key, but against the live environment. Goes in your production frontend code.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:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Key rotation means generating new API keys and deactivating the old ones. You should rotate keys when:
To rotate your keys:
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.
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:
PAYSTACK_SECRET_KEY for the Production environment.Common mistakes that expose keys:
git log. Use git-filter-repo to purge sensitive files from history if this happens.// 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;
Authorization header, your secret key appears in plain text in your log files. Redact it.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 (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:
What you still must do under SAQ A:
What you must never do:
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 is the most common attack on payment integrations. It is also the easiest to prevent. Here is how it works:
charge.success webhook. The transaction status is "success."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.
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.
Your payment initialization endpoint is a target for abuse. Without rate limiting, an attacker can:
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:
Report card testing to Paystack support immediately. They have tools to block these patterns at the gateway level.
Paystack provides data in each transaction response that can help you detect fraud. Here are the signals to watch:
ip_address field 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.authorization object) identify the issuing bank and country. A card issued in the US used on a Nigerian store is higher risk.authorization object includes a reusable flag and an authorization_code. Track how many different accounts use the same authorization.customer object includes a risk_action field. 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.
You need logs to debug payment issues. But payment logs contain sensitive data. Here is what you must never log:
What you should log:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course