Paystack CORS Errors and What They Really Mean
Paystack CORS errors happen because you are calling the Paystack API (api.paystack.co) directly from browser JavaScript. Paystack intentionally does not set CORS headers on its API because server-to-server calls do not need them, and allowing browser calls would expose your secret key. The fix is to move the API call to your backend server. Create a server endpoint that your frontend calls, and have that endpoint call the Paystack API with your secret key. Never call api.paystack.co from frontend code.
What the CORS Error Looks Like
When you call the Paystack API from frontend JavaScript, the browser blocks the request and shows an error like this:
Access to fetch at 'https://api.paystack.co/transaction/initialize'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Or in some browsers:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading
the remote resource at https://api.paystack.co/transaction/initialize.
(Reason: CORS header 'Access-Control-Allow-Origin' missing).
The code that triggers this typically looks like:
// This code is running in the BROWSER. That is the problem.
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxx', // Secret key exposed in browser!
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'user@example.com', amount: 50000 }),
});
// Result: CORS error. The request never reaches Paystack.
This code has two problems: the CORS error, and the fact that your secret key is exposed to anyone who opens browser DevTools.
What CORS Actually Means (And Why Adding Headers Will Not Help)
CORS (Cross-Origin Resource Sharing) is a browser security feature. When your frontend JavaScript on localhost:3000 tries to call api.paystack.co, the browser checks whether Paystack's server allows requests from your domain. It does this by looking for an Access-Control-Allow-Origin header in Paystack's response.
Paystack does not include this header. On purpose.
Here is the part most developers get wrong: you cannot fix this by adding CORS headers to your own server. The CORS check happens on the target server (Paystack's server), not on your server. You do not control Paystack's response headers.
Three "fixes" that do not work:
- Adding CORS middleware to your Express/Next.js server. This adds headers to your server's responses, not to Paystack's responses. Your browser is complaining about Paystack's headers, not yours.
- Using a CORS proxy. Services like cors-anywhere route your request through a proxy that strips CORS restrictions. For a payment API, this means your secret key passes through a third-party server. Never do this with financial data.
- Browser extensions that disable CORS. These only work on your machine. Your users do not have the extension. And they disable a security feature that exists for good reason.
The real fix is architectural: move the API call to your server.
The Correct Architecture: Frontend to Server to Paystack
The correct Paystack integration has three steps:
- Your frontend sends payment details to your server
- Your server calls Paystack's API with your secret key
- Your server returns the result to your frontend
The browser never talks to api.paystack.co directly. Only your server does. Server-to-server calls do not have CORS restrictions because CORS is a browser-only security feature.
// Step 1: Frontend calls YOUR server
// frontend/checkout.js
async function initiatePayment(email, amount) {
const response = await fetch('/api/payments/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount }),
});
const data = await response.json();
if (data.authorization_url) {
// Redirect to Paystack checkout
window.location.href = data.authorization_url;
}
}
// Step 2: Your server calls Paystack
// server/routes/payments.js
const express = require('express');
const router = express.Router();
router.post('/api/payments/initialize', async (req, res) => {
const { email, amount } = req.body;
// Server-to-server call. No CORS. Secret key stays on the server.
const paystackResponse = 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,
amount: amount * 100, // Convert to kobo
}),
});
const data = await paystackResponse.json();
// Step 3: Return the result to the frontend
res.json(data.data);
});
module.exports = router;
Notice that the secret key (PAYSTACK_SECRET_KEY) only exists on the server. The frontend never sees it. The frontend only knows the URL of your own server endpoint.
The Popup Pattern (No Server Call Needed for Initialization)
If you use Paystack's Inline.js popup, you do not need to call /transaction/initialize at all. The popup handles initialization internally using your public key:
<script src="https://js.paystack.co/v2/inline.js"></script>
<script>
function payWithPaystack() {
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx', // Public key is safe in the browser
email: 'customer@example.com',
amount: 50000, // 500 NGN in kobo
currency: 'NGN',
callback: function(response) {
// Payment complete. Verify on your server.
fetch('/api/payments/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference: response.reference }),
});
},
onClose: function() {
console.log('Payment window closed');
},
});
handler.openIframe();
}
</script>
With this pattern, the only server call you need is verification after the payment completes. The popup uses the public key (pk_test_ or pk_live_), which is safe to expose in the browser.
You still need a server endpoint for verification:
// server/routes/payments.js
router.post('/api/payments/verify', async (req, res) => {
const { reference } = req.body;
const paystackResponse = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await paystackResponse.json();
if (data.data.status === 'success') {
// Grant access, send receipt, update database
res.json({ verified: true });
} else {
res.json({ verified: false, status: data.data.status });
}
});
The verification call uses your secret key, so it must happen on the server. If you try to call /transaction/verify from the browser, you will get the same CORS error.
Correct Pattern in Next.js
In Next.js, your API route acts as the server layer. The frontend component calls the API route, and the API route calls Paystack:
// app/api/payments/initialize/route.ts (App Router)
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, amount } = await request.json();
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({ email, amount: amount * 100 }),
});
const data = await response.json();
if (!data.status) {
return NextResponse.json({ error: data.message }, { status: 400 });
}
return NextResponse.json({
authorization_url: data.data.authorization_url,
reference: data.data.reference,
});
}
// app/checkout/page.tsx (Client Component)
'use client';
async function handlePayment() {
const response = await fetch('/api/payments/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'customer@email.com',
amount: 5000,
}),
});
const data = await response.json();
if (data.authorization_url) {
window.location.href = data.authorization_url;
}
}
The client component calls /api/payments/initialize on your own Next.js server. No CORS issues because the request stays on the same origin. The API route then calls Paystack server-to-server.
Mistakes That Lead to CORS Errors
These are the patterns that cause CORS errors. If you recognize any of them in your code, move the call to your server.
Mistake 1: Calling /transaction/initialize from the frontend.
// WRONG: This runs in the browser
const res = await axios.post('https://api.paystack.co/transaction/initialize', {
email: 'user@example.com',
amount: 50000,
}, {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
});
// CORS error + secret key exposed
Mistake 2: Calling /transaction/verify from the frontend.
// WRONG: Verification must happen on the server
const res = await fetch(`https://api.paystack.co/transaction/verify/${reference}`, {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
});
// CORS error + secret key exposed
Mistake 3: Calling any Paystack endpoint from a React useEffect or event handler.
// WRONG: useEffect runs in the browser
useEffect(() => {
fetch('https://api.paystack.co/bank', {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
}).then(res => res.json()).then(setBanks);
}, []);
// CORS error + secret key exposed
Mistake 4: Using a CORS proxy to bypass the restriction.
// DANGEROUS: Your secret key passes through a third party
const res = await fetch('https://cors-anywhere.herokuapp.com/https://api.paystack.co/transaction/initialize', {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
});
// "Works" but your secret key is now visible to the proxy operator
In every case, the fix is the same: create a server endpoint and move the Paystack call there.
Why This Matters: The Security Problem
The CORS error is actually saving you from a security breach. If Paystack allowed browser-origin API calls, your secret key would be visible to anyone who opens browser DevTools on your site. With your secret key, an attacker could:
- Initialize transactions on your account
- View all your customer data
- Initiate transfers from your Paystack balance
- List all your transaction history
- Create and manage subaccounts
This is why Paystack gives you two key types:
- Public keys (
pk_test_,pk_live_) are safe for the browser. They can only initialize the checkout popup. They cannot read data or initiate transfers. - Secret keys (
sk_test_,sk_live_) have full API access. They must stay on your server.
If your secret key has ever been exposed in frontend code (even in a Git commit that was later deleted), regenerate it immediately in your Paystack dashboard under Settings > API Keys & Webhooks.
Verification: Confirm the CORS Error Is Gone
After moving your Paystack API calls to the server, verify the fix:
Step 1: Check your frontend code.
Search your entire frontend codebase for api.paystack.co. There should be zero matches. Every Paystack API call should go through your own server endpoints.
# Search for direct Paystack API calls in frontend code
grep -r "api.paystack.co" src/
# Should return no results (or only results in server-side files)
Step 2: Check your frontend code for secret keys.
# Search for secret keys in frontend code
grep -r "sk_test_|sk_live_" src/
# Should return no results
Step 3: Test the payment flow.
Open your browser DevTools, go to the Console tab, and complete a test payment. There should be no CORS errors. The Network tab should show requests to your own server (e.g., /api/payments/initialize), not to api.paystack.co.
Step 4: Verify the server endpoint works.
// Quick test from your terminal (not the browser)
curl -X POST http://localhost:3000/api/payments/initialize -H "Content-Type: application/json" -d '{"email": "test@example.com", "amount": 500}'
// Should return { "authorization_url": "https://checkout.paystack.com/...", ... }
If the curl command works but the browser still shows CORS errors, then you do need CORS headers on your own server, but only for your own endpoints. This is different from the original problem. Use the cors middleware in Express or configure CORS in your framework to allow your frontend origin.
Key Takeaways
- ✓CORS errors on Paystack mean you are calling the Paystack API from browser JavaScript. This is a security mistake, not a configuration problem.
- ✓Paystack intentionally blocks browser-origin API calls. Adding CORS headers to your own server does not fix this because the error is on Paystack's domain, which you do not control.
- ✓The correct pattern: your frontend calls your server, your server calls Paystack. Your secret key never touches the browser.
- ✓The only Paystack resource designed for browsers is the Inline.js checkout script (js.paystack.co/v2/inline.js). Everything else goes through your server.
- ✓If you see "No Access-Control-Allow-Origin header is present on the requested resource", that is Paystack telling you this call should come from a server.
- ✓CORS proxies and browser extensions that bypass CORS are dangerous for payment integrations. They expose your secret key to anyone watching network traffic.
- ✓Using fetch() or axios to call api.paystack.co from React, Vue, Angular, or vanilla JS will always fail. This is by design.
Frequently Asked Questions
- Why does Paystack not support CORS?
- Paystack intentionally blocks browser-origin API calls because they would require exposing your secret key in frontend code. Secret keys have full API access including transfers and customer data. By not setting CORS headers, Paystack forces the correct architecture: your frontend talks to your server, and your server talks to Paystack.
- Can I add CORS headers to my server to fix Paystack CORS errors?
- No. The CORS error is on Paystack's domain (api.paystack.co), not on yours. Adding CORS headers to your Express or Next.js server only affects responses from your server. You cannot control the headers that Paystack sends. The fix is to stop calling api.paystack.co from the browser entirely.
- Is it safe to use a CORS proxy for Paystack API calls?
- No. A CORS proxy routes your request through a third-party server, which means your secret key passes through that server in plain text. For a payment API, this is a serious security risk. The proxy operator could see your secret key and misuse it. Always call Paystack from your own server.
- Can I call any Paystack endpoint from the browser?
- The only Paystack resource designed for browsers is the Inline.js checkout script at js.paystack.co/v2/inline.js. This script uses your public key to open the checkout popup. All other Paystack API calls (initialize, verify, transfer, customer, etc.) must happen on your server using your secret key.
- I see CORS errors on my own API endpoint after adding a Paystack call. Why?
- If your frontend and backend run on different origins (e.g., frontend on localhost:3000, backend on localhost:5000), you need CORS headers on your own server to allow the frontend to call it. This is a separate issue from calling Paystack. Add the cors middleware to your Express server or configure CORS in your framework to allow your frontend origin.
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