Storing Paystack Authorization Codes Securely
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.
Why Storage Security Matters
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.
Database Schema Design
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:
- authorization_code_encrypted: Stored as encrypted bytes, not plaintext. The application encrypts before writing and decrypts when reading.
- card_last4 and card_type: Stored in cleartext because they are not sensitive and you need them for display ("Visa ending in 4081").
- exp_month and exp_year: Stored in cleartext so you can query for expiring cards without decrypting authorization codes.
- is_default: Marks the customer's preferred payment method. When a customer has multiple cards, charge the default.
- paystack_signature: Identifies the physical card. Two authorization codes with the same signature represent the same card. Use this to avoid duplicates.
- deactivated_at: Soft-delete instead of hard-delete. When a card expires or is replaced, mark it as deactivated rather than deleting the row. You need the history for reconciliation.
Access Control Patterns
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.
Handling Duplicates and Card Updates
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:
- The customer makes a payment with the new card (through checkout or a dedicated "update payment method" flow).
- You receive the new authorization in the charge.success webhook.
- Save the new authorization and mark it as the default payment method.
- Deactivate the old authorization (set
deactivated_atto now). - If the customer has an active subscription, update the subscription to use the new authorization or cancel and recreate it.
For the full card update flow, see handling card expiry in recurring billing.
Logging and Auditing
You need logs to debug billing issues. But you must not log authorization codes.
What to log
- The payment method ID (your internal ID, not the authorization code)
- Card last4 and card type ("Visa ending in 4081")
- The charge reference
- The charge result (success/failed) and gateway response
- Timestamps for every action
What not to log
- The authorization_code string
- The decrypted authorization code
- The encryption key
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.
PCI Compliance Context
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:
- Encryption at rest: PCI requires it for card data. Apply it to authorization codes too.
- Access control: PCI requires that only authorized personnel access cardholder data. Apply the same restriction to authorization codes.
- Audit logging: PCI requires tracking all access to sensitive data. Log every decryption of an authorization code.
- Network segmentation: PCI recommends isolating cardholder data environments. Isolate your billing service similarly.
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.
Key Rotation and Breach Response
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:
- Rotate your Paystack secret key immediately through the Paystack Dashboard.
- Notify affected customers.
- The old authorization codes become unusable with the new secret key, since the key change affects API authentication, not the authorization codes themselves. But the breach must still be reported and remediated.
For the broader security picture of your Paystack integration, see the complete Paystack engineering guide.
Key Takeaways
- ✓Authorization codes can charge a customer's card for any amount at any time. Treat them as sensitive credentials even though they are not raw card numbers.
- ✓Store authorization codes in a dedicated table with columns for the code, last4, card_type, exp_month, exp_year, reusable flag, and a foreign key to your users table.
- ✓Encrypt the authorization_code column at rest. Application-level encryption (AES-256) gives you control over key management independent of your database provider.
- ✓Never log full authorization codes. Use last4 and card_type in logs and support tools instead.
- ✓Restrict database access to authorization codes. Only your billing service should read them directly. Other services should request charges through an internal API.
- ✓Authorization codes are not PCI-regulated (they are tokens, not card numbers), but following PCI-like discipline protects your customers and your business.
Frequently Asked Questions
- Are Paystack authorization codes considered PCI cardholder data?
- No. Authorization codes are tokens that reference stored card data on Paystack's servers. They cannot be reverse-engineered to recover the original card number. They fall outside PCI DSS scope. However, they can charge money, so treat them as sensitive credentials with encryption, access control, and audit logging.
- Should I encrypt authorization codes if my database already has encryption at rest?
- Database-level encryption (like AWS RDS encryption) protects against physical disk theft but not against application-level breaches or SQL injection attacks. Application-level encryption adds a second layer: even if an attacker reads your database through a compromised application, they see encrypted bytes. Both layers together provide stronger protection.
- How do I handle authorization codes when a customer deletes their account?
- Soft-delete the payment method records by setting deactivated_at. Keep them for your financial audit trail. If your privacy policy requires hard deletion, delete the rows but retain the charge history (without the authorization code) for accounting purposes. The authorization code becomes unusable once you stop making API calls with it.
- Can I store authorization codes in Redis or a cache?
- Avoid caching authorization codes in memory stores like Redis unless the cache is encrypted and access-controlled. If you cache for performance, set a short TTL (seconds, not hours) and ensure the cache is not accessible from other services. The database should remain the source of truth.
- What happens to stored authorization codes if I switch from Paystack to another payment provider?
- Authorization codes are specific to Paystack. They cannot be transferred to Stripe, Flutterwave, or any other provider. When migrating, you need to collect new payment details from customers on the new platform. Deactivate all Paystack authorization codes in your database after migration is complete.
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