Store Paystack authorization codes in a dedicated database table with encryption at rest. Save the code alongside metadata like last4, card_type, exp_month, exp_year, and the reusable flag. Restrict database access so only your billing service can read the codes. Authorization codes are not raw card numbers and fall outside PCI DSS scope, but they can charge money and deserve the same care you would give any sensitive credential.
An authorization code like AUTH_xxxxxxxxxxxxx looks harmless. It is just a string. But anyone who has this string and your Paystack secret key can charge the customer's card. No OTP, no PIN, no customer interaction needed.
If your database is breached and authorization codes leak, the attacker cannot use them directly (they would also need your secret key). But if both your database and your environment variables are compromised, every stored authorization code becomes a way to drain customer accounts.
The goal is defense in depth. Even if one layer fails, the authorization codes remain protected.
This article is part of the subscriptions and recurring billing guide. For how authorization codes are created and used, see authorization codes explained.
Create a dedicated table for payment methods. Do not store authorization codes in a JSON column on your users table. A separate table lets you enforce access controls, add indexes, and handle multiple cards per customer.
CREATE TABLE payment_methods (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
authorization_code_encrypted BYTEA NOT NULL,
card_last4 VARCHAR(4) NOT NULL,
card_type VARCHAR(20),
card_bank VARCHAR(100),
exp_month SMALLINT NOT NULL,
exp_year SMALLINT NOT NULL,
channel VARCHAR(20) DEFAULT 'card',
is_reusable BOOLEAN NOT NULL DEFAULT false,
is_default BOOLEAN NOT NULL DEFAULT false,
paystack_signature VARCHAR(100),
country_code VARCHAR(5),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ
);
CREATE INDEX idx_payment_methods_user ON payment_methods(user_id);
CREATE INDEX idx_payment_methods_expiry ON payment_methods(exp_year, exp_month);
Key design decisions in this schema:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Not every part of your application needs access to authorization codes. A user's profile page needs to display "Visa ending in 4081" but does not need the actual authorization code. Only your billing service needs the decrypted code to make charges.
Service-level isolation
If you use microservices, only the billing service should have the database credentials and encryption key needed to read authorization codes. Other services request charges through an internal API:
// Other services call this internal endpoint
// They never see the authorization code
router.post('/internal/charge', async function(req, res) {
var userId = req.body.userId;
var amount = req.body.amount;
var purpose = req.body.purpose;
// Billing service retrieves the authorization code internally
var paymentMethod = await getDefaultPaymentMethod(userId);
var authCode = await getAuthorizationCode(paymentMethod.id);
var result = await chargeCustomer({
userId: userId,
email: paymentMethod.email,
amount: amount,
authorizationCode: authCode,
purpose: purpose,
});
// Return the result without exposing the authorization code
res.json({
success: result.success,
reference: result.reference,
status: result.status,
});
});
Database-level isolation
Create a separate database user for the billing service with SELECT and INSERT permissions on the payment_methods table. Your application's general database user should not have access to this table at all.
-- Create a restricted role for the billing service
CREATE ROLE billing_service LOGIN PASSWORD 'strong_password_here';
GRANT SELECT, INSERT, UPDATE ON payment_methods TO billing_service;
-- The general application role cannot access payment_methods
-- Do not grant any permissions to it
Admin access
Support staff should never see authorization codes. Build admin tools that display card metadata (last4, card_type, expiry) but mask the code itself. If a support case requires looking up the raw code, require a second approval and log the access.
Customers pay multiple times. Each successful card payment creates a new authorization code, even if they use the same card. Without deduplication, a customer who makes three purchases with the same Visa card will have three authorization codes in your database.
Use the signature field to detect duplicates:
async function saveOrUpdatePaymentMethod(userId, authorization) {
// Check if we already have a card with this signature
var existing = await db.query(
'SELECT id FROM payment_methods WHERE user_id = $1 AND paystack_signature = $2 AND deactivated_at IS NULL',
[userId, authorization.signature]
);
if (existing.rows.length > 0) {
// Same physical card. Update the authorization code (it might be newer)
var encryptedCode = encrypt(authorization.authorization_code);
await db.query(
'UPDATE payment_methods SET authorization_code_encrypted = $1, exp_month = $2, exp_year = $3, updated_at = NOW() WHERE id = $4',
[encryptedCode, parseInt(authorization.exp_month), parseInt(authorization.exp_year), existing.rows[0].id]
);
return existing.rows[0].id;
}
// New card. Insert.
var result = await savePaymentMethod(userId, authorization);
return result;
}
When a customer adds a new card to replace an expiring one:
deactivated_at to now).For the full card update flow, see handling card expiry in recurring billing.
You need logs to debug billing issues. But you must not log authorization codes.
What to log
What not to log
function logChargeAttempt(paymentMethodId, metadata) {
// Safe to log: identifies the card without exposing the auth code
console.log(JSON.stringify({
event: 'charge_attempt',
paymentMethodId: paymentMethodId,
cardDisplay: metadata.cardType + ' ending in ' + metadata.last4,
amount: metadata.amount,
reference: metadata.reference,
timestamp: new Date().toISOString(),
}));
}
function logChargeResult(reference, status, gatewayResponse) {
console.log(JSON.stringify({
event: 'charge_result',
reference: reference,
status: status,
gatewayResponse: gatewayResponse,
timestamp: new Date().toISOString(),
}));
}
Build an audit trail table that records who accessed authorization codes and why:
CREATE TABLE auth_code_access_log (
id SERIAL PRIMARY KEY,
payment_method_id INTEGER REFERENCES payment_methods(id),
accessed_by VARCHAR(100) NOT NULL,
access_reason VARCHAR(200) NOT NULL,
accessed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Every time your billing service decrypts an authorization code, log the access. This gives you a trail for security audits and helps detect unauthorized access patterns.
Authorization codes are tokens, not card numbers. They fall outside the scope of PCI DSS (Payment Card Industry Data Security Standard) because they cannot be reverse-engineered to recover the original card number. Only Paystack, as the token vault operator, can map an authorization code back to card details.
However, PCI compliance is a spectrum, not a binary. Even though authorization codes are technically out of scope, applying PCI-like discipline to their storage protects your customers and your business:
If you are subject to PCI compliance (because you handle actual card data elsewhere in your system), your QSA (Qualified Security Assessor) will likely agree that authorization codes are out of scope. But if you store them carelessly and a breach exposes them alongside other data, the reputational damage and customer impact are real regardless of compliance technicalities.
Treat authorization codes like passwords: encrypt them, restrict access, log usage, and rotate when compromised.
If you suspect your encryption key has been compromised, you need to rotate it and re-encrypt all stored authorization codes.
async function rotateEncryptionKey(oldKey, newKey) {
var allMethods = await db.query(
'SELECT id, authorization_code_encrypted FROM payment_methods WHERE deactivated_at IS NULL'
);
for (var i = 0; i < allMethods.rows.length; i++) {
var row = allMethods.rows[i];
// Decrypt with old key
var plainCode = decryptWithKey(row.authorization_code_encrypted, oldKey);
// Re-encrypt with new key
var newEncrypted = encryptWithKey(plainCode, newKey);
await db.query(
'UPDATE payment_methods SET authorization_code_encrypted = $1, updated_at = NOW() WHERE id = $2',
[newEncrypted, row.id]
);
}
console.log('Re-encrypted ' + allMethods.rows.length + ' authorization codes');
}
If authorization codes themselves are compromised (leaked from your database), the damage is limited. The attacker still needs your Paystack secret key to use them. But you should:
For the broader security picture of your Paystack integration, 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