Running Mastodon or any self-hosted platform means you inherit the fun parts of security too. CSP is one of those controls that looks simple until your admin UI breaks, media stops loading, and WebSockets quietly die in production.

I’ve seen this happen a lot with self-hosted stacks: someone pastes a “secure CSP” from a random blog post, then spends the next two hours figuring out why avatars, custom themes, analytics, embeds, or ActionCable stopped working.

So here’s the practical version.

Why CSP matters for Mastodon

Mastodon is a Rails app with a modern frontend, media uploads, streaming, and often a reverse proxy in front. Self-hosted platforms in general have the same pattern:

  • authenticated admin areas
  • user-generated content
  • file uploads
  • JavaScript-heavy frontend code
  • optional third-party integrations
  • WebSockets or long polling
  • custom branding, themes, or injected snippets

That’s exactly the kind of app where CSP helps contain XSS damage. It won’t fix unsafe rendering bugs, but it can stop injected scripts from running, lock down outbound connections, and reduce the blast radius when something slips through.

For Mastodon specifically, you usually care about:

  • script-src for app JavaScript and any admin customizations
  • style-src if themes or inline styles are involved
  • img-src for local uploads, remote media, emoji, and data: URLs
  • connect-src for API calls and streaming endpoints
  • frame-ancestors to stop clickjacking on admin pages
  • object-src 'none' because nobody needs Flash in 2026

Start with a sane baseline

If you self-host Mastodon and don’t inject third-party scripts, start strict.

A good baseline policy looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' data:;
  connect-src 'self' wss://social.example.com https://social.example.com;
  media-src 'self' https:;
  frame-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

A few opinions here:

  • default-src 'self' is the right starting point.
  • script-src should use nonces if you have any inline scripts at all.
  • strict-dynamic is great when your app bootstraps additional scripts from a trusted nonce-bearing script.
  • style-src 'unsafe-inline' is often the ugly compromise for real apps. I don’t love it, but many platforms still need it.
  • img-src 'self' data: https: is common for avatars, emoji, previews, and remote assets.
  • connect-src must include your streaming/WebSocket origin or parts of Mastodon will fail in weird ways.

If you want ready-made patterns, https://csp-examples.com is useful for comparing policy shapes.

A real-world header, and what it tells us

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

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

This is a pretty normal “real business website” CSP:

  • analytics and tag manager are explicitly allowed
  • consent tooling adds script, style, frame, and connect requirements
  • WebSocket access is allowed with wss://or.headertest.com
  • frame-ancestors 'none', base-uri 'self', form-action 'self', and object-src 'none' are all solid

For a self-hosted Mastodon instance, I’d usually try to avoid this level of third-party sprawl. Every extra domain in script-src and connect-src expands trust. If you run a community platform, that matters.

Nginx example for Mastodon

A lot of Mastodon installs sit behind Nginx, so let’s make this concrete.

server {
    listen 443 ssl http2;
    server_name social.example.com;

    # TLS config omitted

    add_header Content-Security-Policy "
        default-src 'self';
        script-src 'self' 'nonce-$request_id' 'strict-dynamic';
        style-src 'self' 'unsafe-inline';
        img-src 'self' data: https:;
        font-src 'self' data:;
        connect-src 'self' https://social.example.com wss://social.example.com;
        media-src 'self' https:;
        frame-src 'self';
        frame-ancestors 'none';
        base-uri 'self';
        form-action 'self';
        object-src 'none';
    " always;

    location / {
        proxy_pass http://mastodon-web;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }

    location /api/v1/streaming {
        proxy_pass http://mastodon-streaming;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

Two caveats:

First, $request_id is not a proper CSP nonce generator by itself unless you also inject the same value into inline script tags. CSP nonces only work if the header and the HTML match exactly.

Second, if Mastodon itself renders scripts without that nonce, the browser will block them. So don’t blindly add a nonce-based policy at the proxy layer unless your app actually supports it.

If your app can’t emit matching nonces yet, use a simpler policy first:

add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' data:;
    connect-src 'self' https://social.example.com wss://social.example.com;
    media-src 'self' https:;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
    object-src 'none';
" always;

Less elegant, but honest and deployable.

Rails example with nonce support

Mastodon is Rails-based, and Rails has decent CSP support. If you’re building or customizing a Rails self-hosted platform, define CSP in an initializer.

# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src  :self, :https, -> { "'nonce-#{content_security_policy_nonce}'" }, "'strict-dynamic'"
  policy.style_src   :self, :unsafe_inline
  policy.img_src     :self, :data, :https
  policy.font_src    :self, :data
  policy.connect_src :self, "https://social.example.com", "wss://social.example.com"
  policy.media_src   :self, :https
  policy.frame_src   :self
  policy.frame_ancestors :none
  policy.base_uri    :self
  policy.form_action :self
  policy.object_src  :none
end

Rails.application.config.content_security_policy_nonce_generator = ->(request) {
  SecureRandom.base64(16)
}

Rails.application.config.content_security_policy_nonce_directives = %w(script-src)

Then in your view:

<script nonce="<%= content_security_policy_nonce %>">
  window.AppConfig = {
    locale: "<%= I18n.locale %>"
  };
</script>

That’s the part people miss. A nonce policy without nonce-bearing tags is just self-inflicted downtime.

Handling remote media and federation quirks

Mastodon isn’t a simple local-only app. You may proxy or cache remote media, render external preview cards, and fetch content from federated servers.

That affects CSP.

If your instance serves cached remote media from itself, img-src 'self' data: https: is usually enough.

If you directly embed remote assets from arbitrary origins, you may need broader allowances:

img-src 'self' data: https:;
media-src 'self' https:;

I generally avoid opening script-src https: or frame-src https: just because “federation is remote.” Federation data is not a reason to trust remote JavaScript.

Adding analytics without wrecking the policy

If you must add analytics or consent tooling, copy the pattern from the real-world header above: only allow exactly what the vendor needs.

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic' https://www.googletagmanager.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://social.example.com wss://social.example.com https://www.google-analytics.com https://www.googletagmanager.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Be stingy. Don’t add wildcard domains because a vendor doc says so unless you’ve verified they’re actually needed.

Roll out with Report-Only first

For self-hosted platforms, this is the safest path:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://social.example.com wss://social.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-uri /csp-report

And a tiny Rails endpoint:

# config/routes.rb
post "/csp-report", to: "csp_reports#create"
# app/controllers/csp_reports_controller.rb
class CspReportsController < ActionController::API
  def create
    Rails.logger.warn("CSP Report: #{request.raw_post}")
    head :no_content
  end
end

Watch reports, fix breakage, then enforce.

If you prefer modern reporting, check the CSP docs in the official browser and framework documentation you already use. report-uri still shows up a lot because it’s easy to wire up quickly.

Common mistakes on self-hosted platforms

I keep seeing the same ones:

1. Forgetting WebSockets

If streaming breaks, check connect-src.

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

2. Adding a nonce in the header only

A nonce must also appear on the matching <script> tags.

3. Using unsafe-inline in script-src

That defeats a lot of the point. If you need a temporary escape hatch, fine, but treat it as debt.

4. Trusting every CDN under the sun

Third-party script trust is a supply-chain decision, not just a CSP tweak.

5. Breaking the admin panel first

Always test:

  • login
  • compose flow
  • media upload
  • profile editing
  • moderation tools
  • streaming notifications
  • custom emoji rendering

That’s where CSP regressions show up.

A practical final policy

If I were hardening a typical self-hosted Mastodon instance with minimal third parties, I’d start here:

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

Then I’d move toward nonce-based script-src once the app templates support it cleanly.

That’s the real trick with CSP on self-hosted platforms: don’t chase the prettiest policy. Ship the strictest policy your actual stack can support today, verify it under real traffic, and tighten it step by step.

For the syntax details and browser behavior, the official references are still the best place to verify edge cases: