tRPC feels deceptively simple from a CSP perspective. There’s no <script> injection problem in the RPC layer itself, so people assume CSP barely matters.

Then production hits.

Queries fail only in the browser. Subscriptions work locally but die behind a proxy. SSR is fine, client-side navigation breaks. Someone tightens default-src and suddenly your API calls start throwing opaque network errors. I’ve seen this more than once: the app is “secure” on paper, but the CSP doesn’t actually match how tRPC talks over HTTP and WebSockets.

Here are the mistakes I see most often with CSP for tRPC endpoints, and how I’d fix them.

Mistake #1: Assuming default-src covers tRPC requests

This is the classic one.

tRPC requests from the browser are controlled by connect-src, not default-src. If your policy doesn’t explicitly allow your RPC origin there, the browser blocks the request even if default-src 'self' looks correct.

A real-world CSP header from headertest.com shows this clearly:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-YTAyZThmZDAtMTQwOC00ZWYzLTk0M2ItODJiOGFjMjAzZjM5' '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'

Notice how API and WebSocket origins live under connect-src. That’s where your tRPC endpoint belongs too.

Broken policy

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

Why it breaks

Your frontend calls something like:

httpBatchLink({
  url: 'https://api.example.com/trpc',
})

The browser sees a fetch/XHR-style connection and checks connect-src. Since there isn’t one, it falls back to default-src. That fallback only helps if the API origin is allowed there, and in stricter setups it usually isn’t.

Fix

Be explicit:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';

If your tRPC endpoint is same-origin, connect-src 'self' is enough.

Mistake #2: Forgetting WebSocket origins for subscriptions

If you use tRPC subscriptions, SSE, or a WebSocket link, CSP gets stricter fast.

A lot of teams allow the HTTPS API endpoint and forget the wss: endpoint. Browsers treat those as separate origins. https://api.example.com does not automatically allow wss://api.example.com.

Broken policy

Content-Security-Policy:
  connect-src 'self' https://api.example.com;

Broken client

import { createWSClient, wsLink, splitLink, httpBatchLink } from '@trpc/client';

const wsClient = createWSClient({
  url: 'wss://api.example.com/trpc',
});

Queries may work over HTTP, but subscriptions fail.

Fix

Allow both:

Content-Security-Policy:
  connect-src 'self' https://api.example.com wss://api.example.com;

If you terminate TLS or route WebSockets through a different hostname, include that exact origin.

This is one place where copying production patterns helps. The headertest.com header includes both:

connect-src ... https://or.headertest.com wss://or.headertest.com ...

That’s the right idea.

Mistake #3: Allowing https: broadly because debugging was annoying

I get why people do this:

connect-src 'self' https: wss:;

It makes the app work again. It also blows a hole in your policy. Any injected script that runs in the page can now beacon data to basically any HTTPS endpoint.

For a developer audience, I’ll say it plainly: this is a lazy fix, and it defeats the point of a restrictive CSP.

Fix

List the exact origins your tRPC client needs.

Content-Security-Policy:
  connect-src 'self' https://api.example.com wss://api.example.com;

If you also use analytics or consent tooling, include those too, but keep the list intentional. The headertest.com example is a decent model: specific analytics, consent, API, and websocket origins, not a blanket https:.

If you want ready-made policy patterns, https://csp-examples.com is useful for sanity-checking the structure before you tune it to your actual hosts.

Mistake #4: Breaking local development with a production-only CSP

This one wastes hours.

Your app works in production with:

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

Then local dev uses:

  • frontend: http://localhost:3000
  • API: http://localhost:3001
  • WebSocket subscriptions: ws://localhost:3001

Now every browser request gets blocked.

Fix

Use environment-specific CSP values.

Here’s a simple Node/Next-style example:

const isDev = process.env.NODE_ENV !== 'production';

const csp = [
  "default-src 'self'",
  "script-src 'self'",
  `connect-src 'self' ${
    isDev
      ? "http://localhost:3001 ws://localhost:3001"
      : "https://api.example.com wss://api.example.com"
  }`,
  "object-src 'none'",
  "base-uri 'self'",
].join('; ');

If you’re on Next.js and setting headers in next.config.js:

const isDev = process.env.NODE_ENV !== 'production';

const csp = [
  "default-src 'self'",
  "script-src 'self'",
  `connect-src 'self' ${
    isDev
      ? "http://localhost:3001 ws://localhost:3001"
      : "https://api.example.com wss://api.example.com"
  }`,
  "object-src 'none'",
  "base-uri 'self'",
].join('; ');

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: csp,
          },
        ],
      },
    ];
  },
};

Don’t ship localhost entries to production. Don’t test production CSP only in local mode either.

Mistake #5: Confusing SSR/server-side tRPC calls with browser CSP

CSP is a browser-enforced policy. Your server-side code is not constrained by the page’s CSP header.

This matters with tRPC because many apps do both:

  • server-side calls during SSR or in server components
  • browser-side calls after hydration

People see SSR working and assume CSP is fine. It isn’t. The browser still needs connect-src for client-side tRPC calls.

Example

Server-side:

const data = await fetch('https://api.example.com/trpc/post.list');

This succeeds because it runs on the server.

Browser-side hydration later:

const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'https://api.example.com/trpc',
    }),
  ],
});

This fails if connect-src doesn’t allow https://api.example.com.

Fix

Test both paths. If your page renders but client-side refetching fails, look at CSP before blaming tRPC.

Mistake #6: Forgetting that batching still uses the same connect-src rules

Some teams get nervous when they switch from plain HTTP calls to httpBatchLink or httpBatchStreamLink, as if batching needs a special CSP rule.

It doesn’t.

Batching changes request shape, not CSP category. It’s still a fetch/XHR-style browser connection, so connect-src still applies.

Example

import { httpBatchLink } from '@trpc/client';

const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'https://api.example.com/trpc',
    }),
  ],
});

Fix

Don’t overcomplicate the policy. Just allow the origin:

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

What you should check is whether your batching setup routes through a CDN, edge hostname, or API gateway that differs from the URL you thought you were calling.

Mistake #7: Setting CSP only on HTML routes and forgetting API-adjacent surfaces

The browser enforces CSP based on the document response. That means your main HTML page needs the right policy. Setting a CSP header on /api/trpc alone won’t fix blocked browser requests from the page if the page itself has the wrong policy.

I’ve seen teams patch the API layer and wonder why the browser still blocks connections.

Fix

Set the CSP on the document-serving responses, usually your app pages or app shell.

For most apps, that means applying it globally to HTML routes. API responses can have their own headers too, but they don’t replace the document CSP.

Mistake #8: Ignoring CSP violation reports and DevTools errors

When tRPC breaks under CSP, the browser usually tells you exactly why. People still go hunting in application logs first.

Open DevTools. Look for messages like:

  • refused to connect to https://api.example.com/trpc
  • violated the following Content Security Policy directive: connect-src

That’s your answer.

Fix

Use a report-only rollout first when tightening policy:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  connect-src 'self' https://api.example.com wss://api.example.com;
  object-src 'none';
  base-uri 'self';

Then watch what the browser says before enforcing.

For the CSP syntax itself, the official reference is the MDN and browser vendor documentation, but for implementation I usually care more about whether DevTools confirms the exact origin match I expect.

A practical baseline for tRPC

If your app uses:

  • same-origin frontend
  • tRPC over HTTPS on api.example.com
  • subscriptions over wss://api.example.com

this is a solid starting point:

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

If the tRPC endpoint is same-origin, reduce it further:

Content-Security-Policy:
  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';
  object-src 'none';

That’s the main idea: tRPC itself doesn’t need special magic, but the browser transport does. Get connect-src right, include wss:// when you need it, keep origins tight, and test the real client-side path instead of trusting SSR. That’s where most CSP+tRPC bugs actually live.