If you add Meilisearch to a frontend and forget CSP, search is often the first thing to break. Not because Meilisearch is weird, but because CSP is doing exactly what you asked: block outbound requests, workers, inline scripts, and third-party assets unless you explicitly allow them.

I’ve seen this happen a lot with search UIs. Everything works locally, then production starts throwing errors like:

Refused to connect to 'https://search.example.com/indexes/movies/search' because it violates the following Content Security Policy directive: "connect-src 'self'".

That’s the core of CSP for Meilisearch: connect-src. Most Meilisearch integrations are browser code making fetch or XHR requests to your Meilisearch host. If that host is not allowed, search dies.

The minimum CSP you need for Meilisearch

If your app is served from https://app.example.com and your Meilisearch instance is at https://search.example.com, the bare minimum usually looks like this:

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

That policy allows your app to load its own assets and send network requests to Meilisearch.

If you’re using a hosted Meilisearch endpoint, put that exact origin in connect-src.

Why connect-src matters most

Meilisearch frontend libraries usually talk to the API over HTTPS from the browser. That means CSP checks connect-src, not script-src.

Here’s a typical client-side setup:

<div id="search">
  <input id="q" type="search" placeholder="Search movies">
  <pre id="results"></pre>
</div>

<script type="module">
  const searchHost = "https://search.example.com";
  const searchKey = "public_search_key";

  const input = document.getElementById("q");
  const results = document.getElementById("results");

  input.addEventListener("input", async () => {
    const res = await fetch(`${searchHost}/indexes/movies/search`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${searchKey}`
      },
      body: JSON.stringify({ q: input.value, limit: 5 })
    });

    const data = await res.json();
    results.textContent = JSON.stringify(data.hits, null, 2);
  });
</script>

For that code, you need:

  • connect-src https://search.example.com
  • script-src that allows your script delivery model
  • maybe style-src if your UI library injects styles

You do not need to allow the Meilisearch host in script-src unless you are actually loading JavaScript from that host.

A practical policy for a Meilisearch app

Here’s a stronger starting point I’d actually use:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://search.example.com;
  worker-src 'self' blob:;
  object-src 'none';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  upgrade-insecure-requests;

A few opinions here:

  • I prefer nonces over 'unsafe-inline' for scripts.
  • I keep default-src tight and declare the fetch destination explicitly.
  • I add worker-src when using modern frontend tooling because search UIs sometimes pull in code paths that rely on workers or blob URLs.
  • object-src 'none' should be standard unless you enjoy weird plugin-era baggage.

Express example: setting CSP for Meilisearch

If you’re serving a Node app, here’s a simple Express setup with helmet:

import express from "express";
import helmet from "helmet";
import crypto from "node:crypto";

const app = express();

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use((req, res, next) => {
  helmet({
    contentSecurityPolicy: {
      useDefaults: false,
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
        styleSrc: ["'self'"],
        imgSrc: ["'self'", "data:"],
        fontSrc: ["'self'"],
        connectSrc: ["'self'", "https://search.example.com"],
        workerSrc: ["'self'", "blob:"],
        objectSrc: ["'none'"],
        frameAncestors: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"]
      }
    }
  })(req, res, next);
});

app.get("/", (req, res) => {
  res.send(`
    <!doctype html>
    <html>
      <body>
        <input id="q" type="search" placeholder="Search">
        <pre id="results"></pre>
        <script nonce="${res.locals.cspNonce}">
          const host = "https://search.example.com";
          const key = "public_search_key";
          const q = document.getElementById("q");
          const results = document.getElementById("results");

          q.addEventListener("input", async () => {
            const r = await fetch(host + "/indexes/movies/search", {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                "Authorization": "Bearer " + key
              },
              body: JSON.stringify({ q: q.value, limit: 5 })
            });
            const data = await r.json();
            results.textContent = JSON.stringify(data.hits, null, 2);
          });
        </script>
      </body>
    </html>
  `);
});

app.listen(3000);

This is clean, predictable, and easy to debug.

If you use InstantSearch or a Meilisearch UI library

The tricky part isn’t Meilisearch itself. It’s the surrounding UI stack.

You might also need to allow:

  • CDN origins in script-src if you load search libraries from a CDN
  • CDN origins in style-src if the UI ships external CSS
  • worker-src blob: if the library or your bundler uses blob-backed workers
  • img-src https: if result cards display remote thumbnails

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.jsdelivr.net;
  style-src 'self' https://cdn.jsdelivr.net;
  img-src 'self' data: https:;
  connect-src 'self' https://search.example.com;
  worker-src 'self' blob:;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

If you want prebuilt policy patterns, the examples at https://csp-examples.com are handy as a starting point. Still, trim them to your exact asset graph. Over-allowing is the usual mistake.

Don’t expose your admin key

This is not strictly CSP, but it comes up every time someone wires Meilisearch directly from the browser.

Never ship your Meilisearch admin key to the client.

Use:

  • a search-only key in the browser
  • tenant tokens if you need document-level restrictions
  • a backend proxy if your query logic is sensitive

CSP won’t save you from credential mistakes. It only constrains what the browser can load and where it can connect.

Real-world CSP example, and what it teaches

Here’s a real CSP header from headertest.com:

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

The useful bit for Meilisearch is the pattern in connect-src. They explicitly allow API and websocket endpoints instead of throwing everything into default-src and hoping for the best.

If you were adapting that for Meilisearch, the key change would be adding your search origin:

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

And if your app uses analytics plus Meilisearch:

connect-src 'self' https://search.example.com https://*.google-analytics.com https://*.googletagmanager.com;

That’s the right mindset: treat search as an outbound connection dependency and list it there.

Common CSP breakages with Meilisearch

1. Search API blocked by connect-src

Console error usually points right at it.

Fix:

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

2. Inline bootstrapping script blocked

A lot of apps inject config like host, index name, or search key inline.

Bad:

<script>
  window.searchConfig = { host: "https://search.example.com" };
</script>

Better with a nonce:

<script nonce="{{ .CSPNonce }}">
  window.searchConfig = { host: "https://search.example.com" };
</script>

And in CSP:

script-src 'self' 'nonce-abc123';

3. Result thumbnails blocked

If Meilisearch results include remote image URLs, your img-src must allow them.

Example:

img-src 'self' data: https:;

Or tighter:

img-src 'self' data: https://images.examplecdn.com;

4. Dev works, prod fails

Local dev often has looser headers or none at all. Production has real CSP.

Use Content-Security-Policy-Report-Only first if you need to discover what your search UI actually loads:

Content-Security-Policy-Report-Only:
  default-src 'self';
  connect-src 'self' https://search.example.com;
  script-src 'self' 'nonce-test123';
  style-src 'self';
  img-src 'self' data:;
  object-src 'none';

Then tighten based on actual violations.

A good production policy for many Meilisearch setups

If you self-host assets, call Meilisearch over HTTPS, and avoid inline CSS, this is a solid baseline:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{RANDOM_NONCE}}';
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://search.example.com;
  worker-src 'self' blob:;
  object-src 'none';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  report-sample;

That’s usually enough for a serious Meilisearch integration without opening the floodgates.

If you remember one thing, make it this: Meilisearch is mostly a connect-src problem. Start there, keep the rest tight, and don’t “fix” CSP by dropping in 'unsafe-inline' and wildcard hosts everywhere. That’s not a fix. That’s giving up.

For the official Meilisearch integration details and client behavior, check the Meilisearch documentation. For CSP syntax and browser behavior, the official CSP docs are still the source of truth.