Never call the Paystack API from your frontend because it requires your secret key in the Authorization header. Any code that runs in the browser is visible to the user. If your secret key is in frontend JavaScript, anyone can extract it from the browser DevTools and use it to initiate refunds, view customer data, or modify your account. The correct pattern is: your frontend sends a request to your own backend server, and your backend server calls Paystack with the secret key.
Here is what the wrong approach looks like. You are building a React or vanilla JavaScript app, you need to initialize a Paystack transaction, and you write something like this:
// WRONG: Do NOT do this. Secret key is in frontend code.
async function initializePayment(email, amount) {
var response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_abc123def456', // YOUR SECRET KEY IN THE BROWSER
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
amount: amount * 100,
}),
});
var data = await response.json();
window.location.href = data.data.authorization_url;
}
This code will not even work (CORS will block it), but even if it did, it would be a security disaster. Let us break down exactly what goes wrong.
Your Paystack secret key is the master key to your payment account. With it, someone can:
Any JavaScript that runs in the browser is fully visible to the user. There is no way to hide it.
"But I minified and bundled my code"
Minification renames variables and removes whitespace. It does not encrypt or hide string literals. Your secret key is still sitting in the bundle as a plain string. Open the browser DevTools, go to Sources, and search for "sk_live_". It will be right there.
"But I used an environment variable in my React app"
If you are using Create React App, Next.js (client-side), or Vite, and you reference process.env.REACT_APP_PAYSTACK_SECRET or import.meta.env.VITE_PAYSTACK_SECRET in a component, the build tool replaces the variable with the actual value at build time. The compiled JavaScript file contains the literal secret key string. Environment variables in frontend frameworks are compile-time substitutions, not runtime secrets.
"But I put it in a .env file"
The .env file is not shipped to the browser. But if your frontend build process reads the variable and injects it into the bundle, the value ends up in the browser anyway. A .env file protects your key from being committed to Git. It does not protect it from being shipped to the browser if your frontend code references it.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Even if you did not care about security (you should), the browser will not let you call the Paystack API directly. You will get an error like this in your console:
Access to fetch at 'https://api.paystack.co/transaction/initialize'
from origin 'https://yoursite.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
CORS (Cross-Origin Resource Sharing) is a browser security feature. When JavaScript on https://yoursite.com tries to make a request to https://api.paystack.co, the browser first sends a preflight OPTIONS request to Paystack asking if yoursite.com is allowed to make requests. Paystack's API does not include your domain in its Access-Control-Allow-Origin response, so the browser blocks the request.
This is intentional. Paystack's API is designed to be called from servers, not from browsers. The CORS restriction is a safety net that prevents accidental frontend usage. But do not rely on CORS as your security mechanism. CORS only protects against browser-based JavaScript. A malicious user with your secret key can call the API from Postman, curl, or their own server without any CORS restrictions.
Some developers try to work around CORS by using a proxy. They set up a server-side proxy that forwards requests from the frontend to Paystack. If you are doing this, you are already halfway to the correct solution. Just move the Paystack API call logic to the proxy server and keep the secret key there.
The right architecture has three parts. Your frontend talks to your server. Your server talks to Paystack. The secret key never leaves your server.
Customer's Browser ---> Your Server ---> Paystack API
(public key only) (secret key) (processes payment)
Step 1: Frontend sends payment details to your server
// Frontend code (React, vanilla JS, etc.)
async function handlePayClick() {
var response = await fetch('/api/initialize-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: customerEmail,
amount: 5000, // Human-readable amount, your server converts to kobo
orderId: currentOrderId,
}),
});
var data = await response.json();
// Option A: Redirect to Paystack checkout
window.location.href = data.authorization_url;
// Option B: Open popup with Paystack Inline JS
// var popup = new PaystackPop();
// popup.checkout({ accessCode: data.access_code, ... });
}
Step 2: Your server calls Paystack with the secret key
// Server code (Node.js + Express)
var express = require('express');
var app = express();
app.use(express.json());
app.post('/api/initialize-payment', async function(req, res) {
var email = req.body.email;
var amount = req.body.amount;
var orderId = req.body.orderId;
// Validate the request
if (!email || !amount || !orderId) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Look up the order to confirm the amount matches
var order = await db.orders.findById(orderId);
if (!order || order.amount !== amount) {
return res.status(400).json({ error: 'Invalid order' });
}
// Call Paystack from the server with the secret key
var 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({
email: email,
amount: Math.round(amount * 100), // Convert to kobo
reference: 'order_' + orderId,
callback_url: 'https://yoursite.com/payment/callback',
}),
});
var data = await response.json();
if (data.status) {
// Send the authorization URL and access code to the frontend
// The secret key stays on the server
res.json({
authorization_url: data.data.authorization_url,
access_code: data.data.access_code,
reference: data.data.reference,
});
} else {
res.status(400).json({ error: data.message });
}
});
Step 3: Frontend uses the authorization URL or access code
Your frontend receives the authorization URL or access code from your server and either redirects the customer or opens a Paystack popup. The secret key never appeared in any frontend code. The customer's browser never made a request to api.paystack.co.
Moving the API call to the server is not just about hiding the key. It also lets you validate the payment request before it reaches Paystack.
If the frontend calls Paystack directly, a malicious user can modify the request. They can change the amount to 1 kobo, change the email, or remove the reference. Your server has no chance to validate the request because it never sees it.
With the correct pattern, your server sits between the frontend and Paystack. Before calling Paystack, your server can:
// Server-side validation before calling Paystack
app.post('/api/initialize-payment', async function(req, res) {
var userId = req.session.userId; // From your auth middleware
var orderId = req.body.orderId;
// 1. Check user is logged in
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
// 2. Look up the order
var order = await db.orders.findById(orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// 3. Check the order belongs to this user
if (order.userId !== userId) {
return res.status(403).json({ error: 'Not your order' });
}
// 4. Check the order is not already paid
if (order.status === 'paid') {
return res.status(400).json({ error: 'Order already paid' });
}
// 5. Use the order amount from the database, NOT from the request
var amountInKobo = Math.round(order.amount * 100);
// 6. Generate a deterministic reference
var reference = 'order_' + orderId;
// Now call Paystack with validated, trusted data
// ...
});
Notice that the amount comes from the database, not from the request body. This is critical. If you take the amount from the frontend, a user can change it to any value they want.
Paystack gives you two key pairs (one for test, one for live):
pk_test_ or pk_live_): Safe to use in frontend code. Used by Paystack Inline JS to identify your account. Cannot be used to call the Paystack API. Cannot be used to read data, initiate refunds, or make transfers.sk_test_ or sk_live_): Must stay on your server. Used in the Authorization header for all API calls. Has full access to your Paystack account.Where each key belongs:
| Key | Where | How |
|---|---|---|
| Public key (pk_) | Frontend code | Passed to PaystackPop configuration or Inline JS |
| Secret key (sk_) | Server environment variables | process.env.PAYSTACK_SECRET_KEY (never in source code) |
The public key appears in your HTML when you use Paystack Inline JS:
<script src="https://js.paystack.co/v2/inline.js"></script>
<script>
var popup = new PaystackPop();
popup.checkout({
key: 'pk_live_your_public_key_here', // Public key is fine here
accessCode: accessCodeFromYourServer,
onSuccess: function(transaction) {
// Verify on your server
},
});
</script>
This is safe. The public key only identifies your Paystack account to the Inline JS library. It cannot be used to call the REST API, view transactions, or move money. It is designed to be public.
If you have already shipped frontend code containing your secret key, act immediately:
Key rotation is instant on Paystack's end. The moment you generate a new key, the old one stops working. Any requests using the old key will fail with a 401 Unauthorized error. Make sure you update all servers and services that use the key before or immediately after rotation.
If you find unauthorized transactions, contact Paystack support immediately. They can help investigate and potentially reverse fraudulent activity.
Here is how the correct pattern looks in different setups you are likely using.
Next.js (API Route)
// pages/api/initialize-payment.js (or app/api/initialize-payment/route.js)
// This runs on the server, never in the browser
module.exports = async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
var 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({
email: req.body.email,
amount: Math.round(req.body.amount * 100),
}),
});
var data = await response.json();
res.json(data.data);
};
Express.js (standalone backend)
The examples earlier in this article use Express. The key point: your Express server is a separate process from your frontend. The frontend calls /api/initialize-payment on your Express server, and Express calls Paystack.
Serverless functions (Vercel, Netlify, AWS Lambda)
Serverless functions run on the server. They are a valid place for your secret key. Set PAYSTACK_SECRET_KEY as an environment variable in your serverless platform's dashboard (Vercel Environment Variables, Netlify Environment Variables, AWS Lambda environment configuration).
The universal rule applies to all frameworks: if the code runs in the browser, no secret key. If the code runs on a server (including serverless functions, API routes, and Edge Functions), the secret key is safe there.
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