I’ve seen too many CSP implementation guides that show you a perfect policy for a perfect application and pretend everything will just work. In the real world, implementing CSP is messy. You’ll hit issues you didn’t expect. Your CMS will inject inline scripts you forgot about. That analytics tool your marketing team added last quarter? It breaks everything.
This guide is for people implementing CSP on real applications. Not demos. Not fresh create-react-app projects. The kind of application that has accumulated technical debt, third-party scripts, and “temporary” hacks that have been there for three years.
Step 1: Know What You’re Loading
Before writing a single CSP directive, you need to understand what your page actually loads. Open Chrome DevTools, go to the Network tab, reload the page, and look at everything.
Sort by type and write it down:
- Every JavaScript file and where it comes from
- Every external script tag (analytics, chat widgets, A/B testing)
- Every inline
<script>tag (yes, even the small ones) - Every stylesheet source
- Every font source
- Every image domain
- Every API endpoint your JavaScript calls
- Every iframe
Most developers are genuinely surprised by how much their page loads. You probably have scripts you didn’t know existed — added by a WordPress plugin, a CMS module, or a colleague who “just needed to test something real quick.”
Step 2: Deploy Report-Only (And Accept the Chaos)
Here’s your first CSP policy. It’s intentionally strict:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
report-uri /api/csp-report
Add this header to your server responses. Every request to every page. Don’t be selective — you want to catch everything.
Yes, your browser console will light up like a Christmas tree. That’s the point. Every one of those violations is something you need to account for in your policy.
Step 3: Set Up the Report Endpoint
You need somewhere to send those violation reports. Here’s a minimal Node.js endpoint:
app.post('/api/csp-report',
express.json({ type: 'application/csp-report' }),
(req, res) => {
const report = req.body['csp-report'];
// Log to your preferred service
console.error('CSP Violation:', {
page: report['document-uri'],
blocked: report['blocked-uri'],
directive: report['violated-directive'],
source: report['source-file'],
line: report['line-number'],
});
res.status(204).end();
}
);
```text
Or if you're using PHP:
The nonce changes on every request, so even if an attacker injects a `<script>` tag, they can't predict the correct nonce.
### Option C: Use 'unsafe-inline' (Acceptable Compromise)
script-src ‘self’ ‘unsafe-inline’
I know what you're thinking: "But the whole point of CSP is to block inline scripts!" Yes, and if you can avoid unsafe-inline, you should. But if you're dealing with a legacy WordPress site with 47 plugins that all inject inline scripts, unsafe-inline is better than no CSP at all. You still get `frame-ancestors`, `connect-src`, and `default-src` protections.
## Step 7: Switch to Enforcement
After running report-only with near-zero violations for at least a week:
Content-Security-Policy: default-src ‘self’; script-src ‘self’ ’nonce-RANDOM’ https://www.googletagmanager.com; style-src ‘self’ ‘unsafe-inline’ https://fonts.googleapis.com; img-src ‘self’ data: https:; font-src ‘self’ https://fonts.gstatic.com; connect-src ‘self’ https://www.google-analytics.com; frame-ancestors ’none’; base-uri ‘self’; form-action ‘self’; upgrade-insecure-requests; report-uri /api/csp-report
Note: I kept `report-uri` even in enforcement mode. This way you'll still catch new violations when someone adds a new third-party script or makes a change that breaks the policy.
## Common Mistakes I See Repeatedly
**1. Using 'unsafe-inline' AND 'unsafe-eval' for script-src.** If you're doing this, you might as well not have script-src at all. Eval is one of the most commonly exploited JavaScript functions. If your app requires eval(), refactor it. There's almost always a better way.
**2. Wildcard origins.** `script-src *` or `script-src https:` defeats the purpose. Be specific about which domains you allow.
**3. Forgetting about service workers.** If you use service workers, add `worker-src 'self'` to your policy.
**4. Not handling the admin area separately.** Your public-facing site can have a strict policy, but your CMS admin panel might need a more permissive one. That's fine — use different policies for different paths.
**5. Testing only in Chrome.** Different browsers implement CSP slightly differently. Test in Firefox and Safari too.
## Server Configuration Examples
### Nginx
location / { add_header Content-Security-Policy “default-src ‘self’; script-src ‘self’; style-src ‘self’ ‘unsafe-inline’; img-src ‘self’ data: https:; font-src ‘self’; frame-ancestors ’none’; base-uri ‘self’; form-action ‘self’” always; try_files $uri $uri/ =404; }
### Apache
PHP (in your framework or index.php)