PDF.js is one of those libraries that looks simple until CSP gets involved. You drop in the viewer, load a PDF, and everything works locally. Then you turn on a real Content Security Policy and suddenly the worker fails, fonts disappear, images stop rendering, or the whole viewer goes blank with a useless console error.

I’ve hit this a few times. The pattern is usually the same: people start with a generic CSP, then keep adding exceptions until the app works again. That usually ends with unsafe-inline, unsafe-eval, and a policy that technically exists but doesn’t really protect anything.

If you’re serving a PDF.js viewer, these are the mistakes I see most often, and the fixes that actually hold up.

The baseline problem with PDF.js and CSP

PDF.js is not just a single script. The viewer usually involves:

  • the main application script
  • a worker script
  • stylesheets
  • fonts
  • images
  • network requests for the PDF file
  • sometimes blob URLs, depending on build and configuration

That means a CSP for PDF.js needs more than a basic default-src 'self'.

A decent starting point looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: blob:;
  font-src 'self' data:;
  connect-src 'self';
  worker-src 'self' blob:;
  child-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

That is not universal, but it’s a sane baseline for a self-hosted PDF.js viewer.

Mistake #1: Assuming default-src 'self' covers everything

This is probably the most common one.

People write:

Content-Security-Policy: default-src 'self';

Then they’re surprised when the PDF.js worker is blocked, fonts fail, or the PDF fetch request gets denied.

default-src is only the fallback. Some resource types have their own directives, and PDF.js tends to use several of them. If you don’t explicitly define them, you’re relying on fallback behavior that may not match what the viewer actually needs.

Fix

Declare the directives PDF.js actually uses:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: blob:;
  font-src 'self' data:;
  connect-src 'self';
  worker-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

If you want a refresher on how fetch directives inherit from default-src, the directive references at https://csp-guide.com are useful.

Mistake #2: Forgetting the PDF fetch itself uses connect-src

This catches people because the PDF file feels like “content,” not an API request. But if PDF.js fetches it with fetch() or XHR, CSP checks connect-src.

So this breaks:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';

And your code:

pdfjsLib.getDocument('/docs/report.pdf').promise.then((pdf) => {
  console.log('loaded', pdf.numPages);
});

If the PDF comes from another origin, a CDN, or even a dedicated file host, connect-src needs to allow it.

Fix

Allow the exact origin used for PDF downloads:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  connect-src 'self' https://files.example.com;
  worker-src 'self' blob:;
  img-src 'self' data: blob:;
  font-src 'self' data:;
  object-src 'none';

Be specific. Don’t turn connect-src into https: unless you like debugging future surprises.

Mistake #3: Worker blocked because worker-src is missing

PDF.js relies heavily on a worker. If the worker can’t load, performance tanks or rendering fails completely depending on your setup.

A lot of teams still forget worker-src, especially because older CSP setups leaned on child-src. Modern browsers support worker-src, and you should set it directly.

Typical failure

Content-Security-Policy:
  default-src 'self';
  script-src 'self';

And in JavaScript:

pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.js';

The main script loads, but the worker is blocked by CSP.

Fix

Allow the worker source explicitly:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  worker-src 'self';
  child-src 'self';

If your build uses blob workers, you may also need:

worker-src 'self' blob:;
child-src 'self' blob:;

I usually include both worker-src and child-src when supporting mixed browser behavior. It avoids painful edge-case debugging.

Mistake #4: Using unsafe-inline for viewer styles because “it’s easier”

I get why people do this. PDF.js viewer UI can be style-heavy, and once something breaks, slapping on:

style-src 'self' 'unsafe-inline';

feels like the shortest path to green.

It’s also usually unnecessary.

The real-world header from headertest.com includes:

style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;

That kind of policy is common on marketing-heavy sites, but for a PDF.js viewer, I’d avoid inheriting that habit unless you’ve actually verified inline styles are required.

Fix

Serve CSS as external files and keep style-src tight:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';

If you truly need inline styles for your app shell, prefer a nonce or hash over unsafe-inline.

Example with a nonce:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self' 'nonce-r4nd0m';
<style nonce="r4nd0m">
  #viewerContainer { inset: 0; }
</style>

For a dedicated PDF viewer route, I’d keep third-party tag managers out entirely if possible.

Mistake #5: Missing img-src data: and font-src data:

PDF.js often renders assets and embedded content in ways that involve data URLs. If your CSP only allows self, you can get weird partial rendering issues: pages render, but some images or fonts don’t.

This is the sort of bug that wastes hours because the viewer “mostly works.”

Fix

Be explicit:

Content-Security-Policy:
  default-src 'self';
  img-src 'self' data: blob:;
  font-src 'self' data:;

I usually allow blob: for img-src too, because some rendering paths end up using blob-backed resources.

Mistake #6: Keeping object-src open “just in case PDFs need it”

They don’t, if you’re using PDF.js correctly.

I still see policies where object-src is omitted, which means it falls back to default-src, or worse, explicitly allows plugins. That’s old baggage from browser PDF plugins and embedded document handling.

For a PDF.js viewer, object-src should be locked down.

Fix

Set it to none:

object-src 'none';

This is one of the easiest wins in CSP. No downside for a normal PDF.js deployment.

Mistake #7: Hosting viewer assets on one origin and worker on another without aligning CSP

A split setup is common:

  • app at app.example.com
  • static assets at static.examplecdn.com

Then someone points the worker to the CDN:

pdfjsLib.GlobalWorkerOptions.workerSrc =
  'https://static.examplecdn.com/pdfjs/pdf.worker.min.js';

But CSP still says:

worker-src 'self';
script-src 'self';

That will fail.

Fix

Allow the CDN in the directives that matter:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://static.examplecdn.com;
  worker-src 'self' https://static.examplecdn.com;
  style-src 'self' https://static.examplecdn.com;
  font-src 'self' data: https://static.examplecdn.com;
  img-src 'self' data: blob: https://static.examplecdn.com;

If you self-host everything on one origin, do that. It makes CSP dramatically simpler.

Mistake #8: Copy-pasting a site-wide CSP into the viewer route

This is the silent killer.

A site-wide CSP often includes analytics, consent tools, A/B testing, maybe some strict-dynamic, maybe inline styles, maybe multiple third-party domains. The real header from headertest.com is a good example of a production policy shaped by broader site requirements:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MDM4ZjMyMTgtNWM5MS00MDZiLTg4NTAtOGYyYmM5MWZlYTA0' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

That may be perfectly reasonable for a main website. It’s not the policy I’d want for a dedicated PDF.js viewer.

Fix

Give the viewer its own route-level CSP.

For example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: blob:;
  font-src 'self' data:;
  connect-src 'self' https://files.example.com;
  worker-src 'self' blob:;
  child-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  form-action 'none';
  frame-ancestors 'none';

This is cleaner, easier to reason about, and much safer than dragging marketing dependencies into a document viewer.

Mistake #9: Debugging CSP without using report-only first

If you’re tightening CSP around PDF.js, don’t start with enforcement unless you enjoy breaking production.

Fix

Roll out with report-only first:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: blob:;
  font-src 'self' data:;
  connect-src 'self' https://files.example.com;
  worker-src 'self' blob:;
  object-src 'none';

Watch the console and your reports, then switch to enforcement once the policy is clean.

A practical CSP for a self-hosted PDF.js viewer

If I were shipping a straightforward viewer today, I’d start here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: blob:;
  font-src 'self' data:;
  connect-src 'self';
  worker-src 'self' blob:;
  child-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  form-action 'none';
  frame-ancestors 'none';

And if the PDF files come from a separate origin:

connect-src 'self' https://files.example.com;

That’s usually enough without sliding into weak exceptions.

The big rule here is simple: build the CSP around how PDF.js actually loads resources, not around a generic website template. Most CSP pain with PDF.js comes from treating it like a normal script include. It isn’t. It’s a mini application with a worker, fetches, fonts, and rendering paths that need explicit allowance.

If you keep the policy route-specific and resist the urge to “just add unsafe-inline,” you’ll end up with something both secure and maintainable.