Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 9910ba137961da2311a4f8d43d24b8152f088818)
+++ src/middleware/render.js	(revision 73abbfd894337dbe548ee2838a190c2ebfbd91b7)
@@ -21,4 +21,13 @@
 import ActivityPubService from '../services/ActivityPubService.js';
 import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
+
+// Add the per-request CSP nonce to every <script> tag that doesn't already have one, so the
+// strict script-src (nonce + 'strict-dynamic') allows them — including scripts in htmx
+// partials. HTML-escaped "&lt;script" in rendered content (e.g. sanitized post bodies) won't
+// match, so this only touches real tags.
+function injectCspNonce(html, nonce) {
+  if (!html || !nonce) return html;
+  return String(html).replace(/<script(?![^>]*\snonce=)/gi, () => `<script nonce="${nonce}"`);
+}
 import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
 import { t as i18nT, resolveLang, SUPPORTED as LANGS, LANG_NAMES } from '../services/i18n.js';
@@ -204,10 +213,11 @@
         );
       } catch (e) { /* skip chrome OOB */ }
-      return res.send(pageContent + oobChrome);
+      return res.send(injectCspNonce(pageContent + oobChrome, res.locals.cspNonce));
     }
 
-    // Full: wrap content in shell
+    // Full: wrap content in shell (rendered to a string so we can inject the CSP nonce).
     locals.pageContent = pageContent;
-    res.render('shell', locals);
+    const shellHtml = await ejs.renderFile(path.join(VIEWS_DIR, 'shell.ejs'), locals, { async: false });
+    res.send(injectCspNonce(shellHtml, res.locals.cspNonce));
   } catch (err) {
     console.error('[renderPage] Error rendering', viewName, err);
Index: src/server.js
===================================================================
--- src/server.js	(revision 9910ba137961da2311a4f8d43d24b8152f088818)
+++ src/server.js	(revision 73abbfd894337dbe548ee2838a190c2ebfbd91b7)
@@ -91,19 +91,21 @@
 const server = http.createServer(app);
 
+// Per-request CSP nonce for the strict script-src (nonce + strict-dynamic). Must be set
+// before helmet builds the CSP header below. The nonce is injected into every <script> tag
+// at render time (see middleware/render.js injectCspNonce).
+app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); next(); });
+
 app.use(helmet({
   contentSecurityPolicy: {
     directives: {
       defaultSrc: ["'self'"],
+      // Strict CSP: a per-request nonce + 'strict-dynamic' (no 'unsafe-inline', no broad host
+      // sources — securityheaders/Observatory flag those). Trusted (nonce'd) scripts may load
+      // further scripts, which covers htmx-swapped inline scripts AND the external player APIs
+      // that embed-player.js injects (YouTube/SoundCloud/Spotify). The nonce is added to every
+      // <script> tag at render time (middleware/render.js injectCspNonce).
       scriptSrc: [
-        "'self'",
-        "'unsafe-inline'",
-        // Our custom embeds (embed-player.js) load the OFFICIAL player APIs
-        // from these hosts. Without this whitelist the CSP silently blocks them
-        // (only a console error) and the embed player fails.
-        "https://www.youtube.com",   // YouTube IFrame Player API (+ www-widgetapi.js)
-        "https://s.ytimg.com",       // YouTube player assets
-        "https://w.soundcloud.com",  // SoundCloud Widget API (api.js)
-        "https://open.spotify.com",  // Spotify iFrame API (loader)
-        "https://*.spotifycdn.com",  // Spotify iFrame API (real bundle: embed-cdn.spotifycdn.com)
+        "'strict-dynamic'",
+        (req, res) => `'nonce-${res.locals.cspNonce}'`,
       ],
       // Helmet's default sets script-src-attr to 'none', which blocks ALL inline
