Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Checkout Blocked by Content Security Policy

Your CSP is blocking Paystack because you have not whitelisted the domains Paystack needs. Add js.paystack.co to script-src, standard.paystack.co to frame-src, and api.paystack.co to connect-src. If you use Helmet in Express/Node.js, configure the contentSecurityPolicy middleware with these exact domains. Without all three directives, the checkout popup will fail silently or throw a console error.

What the Error Looks Like

When CSP blocks Paystack, you will see one or more of these errors in your browser console:

Refused to load the script 'https://js.paystack.co/v2/inline.js' because it
violates the following Content Security Policy directive: "script-src 'self'"

Refused to frame 'https://standard.paystack.co/' because it violates the
following Content Security Policy directive: "frame-src 'self'"

Refused to connect to 'https://api.paystack.co/transaction/initialize' because
it violates the following Content Security Policy directive: "connect-src 'self'"

The checkout popup does not appear. No error fires in your JavaScript code because the script never loaded in the first place. Your PaystackPop or paystack variable is undefined, and any code that calls it throws a TypeError.

This is not a Paystack bug. Your server is sending a Content-Security-Policy header (or you have a <meta> tag) that tells the browser to block external resources. The browser is following your instructions.

Why CSP Blocks Paystack

Content Security Policy is a security layer that tells the browser which domains are allowed to load scripts, frames, images, and other resources on your page. If you set script-src 'self', the browser only allows scripts from your own domain. Paystack's script lives on js.paystack.co, so the browser blocks it.

This problem typically appears when you:

  • Add Helmet.js to an Express app (Helmet enables a strict CSP by default)
  • Add CSP meta tags to your HTML template
  • Deploy behind a reverse proxy or CDN (like Cloudflare) that injects CSP headers
  • Use a hosting platform that adds security headers automatically
  • Copy a "security best practices" CSP config from a blog post that does not account for payment providers

The fix is not to remove CSP. CSP protects your users from cross-site scripting attacks. The fix is to whitelist the specific domains Paystack needs.

The Three Domains You Must Whitelist

Paystack uses three domains during the checkout process. Each one maps to a different CSP directive:

Domain CSP Directive What It Does
js.paystack.co script-src Serves the Paystack Inline.js script
standard.paystack.co frame-src Hosts the checkout iframe/popup
api.paystack.co connect-src Handles API calls from the checkout

If you whitelist only the script domain but forget the frame domain, the script loads but the checkout popup does not open. If you whitelist only the frame domain, the script never loads in the first place. You need all three.

As a raw CSP header, the minimum viable policy for Paystack looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://js.paystack.co;
  frame-src 'self' https://standard.paystack.co;
  connect-src 'self' https://api.paystack.co;

That is the minimum. Your real policy will include other directives for your own assets, analytics, fonts, and other third-party services.

Fix: Helmet.js in Express / Node.js

Helmet is the most common source of this problem in Node.js apps. By default, helmet() sets a strict CSP that blocks all external scripts. Here is the fix:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "https://js.paystack.co"],
        frameSrc: ["'self'", "https://standard.paystack.co"],
        connectSrc: ["'self'", "https://api.paystack.co"],
        imgSrc: ["'self'", "data:", "https:"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        fontSrc: ["'self'"],
      },
    },
  })
);

Some important details about this config:

  • Use the full URL with https:// prefix. Just writing js.paystack.co without the protocol will not work in all browsers.
  • Keep 'self' in every directive so your own resources still load.
  • Do not add 'unsafe-inline' to script-src. Paystack loads as an external script, not an inline script. Adding 'unsafe-inline' weakens your CSP for no reason.
  • Do not add 'unsafe-eval' either. Paystack does not use eval().

If you want to disable CSP entirely during development (not recommended, but sometimes useful for debugging):

app.use(
  helmet({
    contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "https://js.paystack.co"],
        frameSrc: ["'self'", "https://standard.paystack.co"],
        connectSrc: ["'self'", "https://api.paystack.co"],
      },
    } : false,
  })
);

Fix: CSP via Meta Tag

If you set your CSP using a <meta> tag in your HTML <head>, update it like this:

<meta http-equiv="Content-Security-Policy"
  content="default-src 'self';
    script-src 'self' https://js.paystack.co;
    frame-src 'self' https://standard.paystack.co;
    connect-src 'self' https://api.paystack.co;">

Place this tag before the Paystack script tag. If the browser reads the script tag before the CSP meta tag, the behaviour is unpredictable.

One catch with meta tag CSP: it does not support all directives. Specifically, frame-ancestors and report-uri are ignored in meta tags. If you need those directives, use the HTTP header approach instead.

Also, if your server sends a CSP header and you also have a meta tag, the browser applies both. The effective policy is the intersection of the two, meaning the stricter rule wins. If your server header blocks js.paystack.co and your meta tag allows it, the header wins. Check both places.

Fix: Next.js Security Headers

In Next.js, you typically set CSP in next.config.js using the headers function:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' https://js.paystack.co",
              "frame-src 'self' https://standard.paystack.co",
              "connect-src 'self' https://api.paystack.co",
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
            ].join('; '),
          },
        ],
      },
    ];
  },
};

If you use Next.js middleware to set CSP headers (common with nonce-based CSP), add the Paystack domains there:

// middleware.ts
import { NextResponse } from 'next/server';

export function middleware(request) {
  const nonce = crypto.randomUUID();
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' https://js.paystack.co`,
    `frame-src 'self' https://standard.paystack.co`,
    `connect-src 'self' https://api.paystack.co`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  return response;
}

With nonce-based CSP, your Paystack script tag does not need a nonce attribute because you are whitelisting the entire domain. The nonce is for your own inline scripts.

Fix: Vercel, Cloudflare, and Other Platforms

Some hosting platforms and CDNs add CSP headers at the edge. If you have fixed your app code but still see CSP errors, check these platform-specific locations:

Vercel: Check vercel.json for a headers configuration.

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self' https://js.paystack.co; frame-src 'self' https://standard.paystack.co; connect-src 'self' https://api.paystack.co"
        }
      ]
    }
  ]
}

Cloudflare: Check your Cloudflare dashboard under Security > CSP. Cloudflare can inject CSP headers through Page Rules or Transform Rules. If Cloudflare adds a CSP header and your server also sends one, both apply.

Nginx: Check your nginx config for add_header Content-Security-Policy directives. Remember that add_header in a location block overrides headers from the parent server block.

# nginx.conf
add_header Content-Security-Policy
  "default-src 'self'; script-src 'self' https://js.paystack.co; frame-src 'self' https://standard.paystack.co; connect-src 'self' https://api.paystack.co"
  always;

To find which layer is setting the CSP header, open your browser DevTools, go to the Network tab, click on your page request, and look at the Response Headers. The Content-Security-Policy header tells you the exact policy being enforced.

Testing with Report-Only Mode

Before deploying a new CSP, use the Content-Security-Policy-Report-Only header instead of Content-Security-Policy. Report-only mode logs violations to the console without actually blocking anything. This lets you verify your policy works without breaking Paystack for real users.

// Helmet report-only mode
app.use(
  helmet({
    contentSecurityPolicy: {
      reportOnly: true,
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "https://js.paystack.co"],
        frameSrc: ["'self'", "https://standard.paystack.co"],
        connectSrc: ["'self'", "https://api.paystack.co"],
      },
    },
  })
);

Deploy this to production. Watch your browser console for violation reports. If you see any "would be blocked" messages related to Paystack, you know a domain is missing. Fix it in report-only mode, then switch to enforcement.

You can also set up a report endpoint to collect violations server-side:

// Add to your CSP directives
reportUri: ['/api/csp-report'],

// Then create the endpoint
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  console.log('CSP Violation:', JSON.stringify(req.body, null, 2));
  res.status(204).end();
});

Verification: Confirm Paystack Loads Through CSP

After updating your CSP, verify that Paystack works end to end:

Step 1: Check the CSP header.

# curl your site and inspect the CSP header
curl -s -I https://yoursite.com | grep -i content-security-policy

# You should see js.paystack.co, standard.paystack.co, and api.paystack.co
# in the appropriate directives

Step 2: Check the browser console.

Open DevTools, go to the Console tab, and load your payment page. There should be zero CSP violation messages. If you see any, the header from Step 1 is not what the browser is receiving (check for platform-level overrides).

Step 3: Complete a test payment.

// Quick test: initialize Paystack popup
const handler = PaystackPop.setup({
  key: 'pk_test_your_public_key',
  email: 'test@example.com',
  amount: 10000,  // 100 NGN in kobo
  onClose: () => console.log('Popup closed'),
  callback: (response) => console.log('Payment complete:', response.reference),
});
handler.openIframe();

// If the popup opens and you can enter test card details,
// your CSP is correctly configured

Step 4: Test with a strict CSP checker.

Use Google's CSP Evaluator to paste your CSP header and check for issues. It will flag unsafe directives and missing values. It will not specifically check for Paystack, but it confirms your policy is well-formed.

Key Takeaways

  • CSP blocks Paystack when your policy does not whitelist the three domains Paystack uses: js.paystack.co for the script, standard.paystack.co for the checkout iframe, and api.paystack.co for API calls.
  • The browser console error tells you exactly which directive is blocking Paystack. Look for "Refused to load the script" (script-src), "Refused to frame" (frame-src), or "Refused to connect" (connect-src).
  • You need all three CSP directives, not just one. Missing any single directive will break a different part of the checkout flow.
  • If you use Helmet.js in Express, the default CSP blocks all external scripts. You must explicitly configure contentSecurityPolicy with Paystack domains.
  • Never use unsafe-inline or unsafe-eval to fix Paystack CSP issues. Whitelisting specific domains is the correct and secure approach.
  • Test your CSP in report-only mode first (Content-Security-Policy-Report-Only header) so you can catch blocking issues without breaking your site for users.
  • Meta tag CSP and HTTP header CSP both work, but the HTTP header takes precedence. If you set CSP in both places, the stricter one wins.

Frequently Asked Questions

Which domains do I need to whitelist for Paystack in my CSP?
Three domains: js.paystack.co in script-src (for the Inline.js script), standard.paystack.co in frame-src (for the checkout iframe), and api.paystack.co in connect-src (for API calls made by the checkout). Missing any one of these will break a different part of the checkout flow.
Do I need to add unsafe-inline or unsafe-eval for Paystack?
No. Paystack loads as an external script from its own domain, not as an inline script. It does not use eval(). Adding unsafe-inline or unsafe-eval would weaken your CSP without solving the actual problem. Whitelist the specific Paystack domains instead.
Why does Paystack work locally but fail in production?
Most likely your production server sends a CSP header that your local server does not. This is common with Helmet.js (which adds CSP by default), hosting platforms that inject security headers, or reverse proxies like Cloudflare. Check the response headers in your browser DevTools for both environments and compare the Content-Security-Policy values.
Can I use a wildcard like *.paystack.co in my CSP?
Technically yes, and it would cover all Paystack subdomains. However, wildcards in CSP are considered a security weakness because they allow any subdomain, including ones that might not exist yet. Best practice is to list the exact three domains you need: js.paystack.co, standard.paystack.co, and api.paystack.co.
My CSP header is correct but Paystack is still blocked. What else could it be?
Check for multiple CSP sources. Your server might send a CSP header, and a platform like Cloudflare or Vercel might add another one. When multiple CSP headers are present, the browser enforces all of them, and the strictest one wins. Also check for CSP meta tags in your HTML that might conflict with the header.

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