diff --git a/.env.example b/.env.example
index 8fdf932ddc..e7320aa3a4 100644
--- a/.env.example
+++ b/.env.example
@@ -105,6 +105,61 @@ TRUST_PROXY=1
# resources served by LibreChat, such as uploaded images.
# CROSS_ORIGIN_RESOURCE_POLICY=same-origin
+#===============================#
+# Content Security Policy #
+#===============================#
+
+# Nonce-based CSP for the SPA HTML response. Off by default so existing
+# deployments are unaffected. Turn it on in report-only mode first, review the
+# violations your deployment actually produces, then set CSP_REPORT_ONLY=false.
+# Only an explicit false/off/0/no enforces; anything unrecognized warns and stays
+# report-only, so a typo cannot silently start blocking scripts.
+# CSP_ENABLED=false
+# CSP_REPORT_ONLY=true
+# CSP_REPORT_URI=
+
+# The default policy accommodates what LibreChat actually loads at runtime:
+# script-src 'wasm-unsafe-eval' HEIC image conversion compiles WebAssembly
+# worker-src data: Monaco's loader bootstraps workers from data:
+# Both are narrower than 'unsafe-eval'. Set these to false to drop them if your
+# deployment uses neither HEIC uploads nor the artifact code editor. (The CSP_*_EXTRA
+# and CSP_ADDITIONAL_DIRECTIVES variables only add sources; they cannot remove one.)
+# CSP_ALLOW_WASM=true
+# CSP_ALLOW_DATA_WORKERS=true
+
+# While CSP is enabled the SPA shell is always sent as `no-store` and the
+# INDEX_CACHE_CONTROL / INDEX_PRAGMA / INDEX_EXPIRES overrides are ignored for it.
+# A cached shell would pin a single nonce across page loads and users, which is
+# precisely what a nonce policy exists to prevent.
+#
+# SECURITY_HEADERS=false disables CSP too; it is the global kill switch.
+
+# Add deployment-specific sources on top of LibreChat's defaults; they are
+# appended, never replacing them. Comma- or space-separated. Quote values
+# containing spaces.
+# CSP_CONNECT_SRC_EXTRA="https://telemetry.example.com wss://stream.example.com"
+# CSP_FRAME_SRC_EXTRA="https://tenant.sharepoint.com"
+# CSP_IMG_SRC_EXTRA="https://cdn.example.com"
+# CSP_STYLE_SRC_EXTRA=
+# CSP_FONT_SRC_EXTRA=
+# CSP_MEDIA_SRC_EXTRA=
+# CSP_WORKER_SRC_EXTRA=
+# CSP_FORM_ACTION_EXTRA=
+# CSP_DEFAULT_SRC_EXTRA=
+
+# Script hosts get their own note: the default policy uses 'strict-dynamic',
+# which makes browsers ignore every host source in script-src. Setting this
+# drops 'strict-dynamic' so the hosts you list actually take effect.
+# CSP_SCRIPT_SRC_EXTRA="https://trusted-scripts.example.com"
+
+# Who may frame LibreChat. Defaults to 'self'. Replace it if you embed LibreChat
+# in a portal on another origin, and set X_FRAME_OPTIONS=off alongside it since
+# older browsers honor that header instead.
+# CSP_FRAME_ANCESTORS="'self' https://portal.example.com"
+
+# Raw directives appended to the policy, separated by semicolons.
+# CSP_ADDITIONAL_DIRECTIVES="upgrade-insecure-requests"
+
# Trust X-Tenant-Id on unauthenticated routes. Disabled by default.
# Enable only when a trusted reverse proxy strips any client-supplied value and sets its own.
# TRUST_TENANT_HEADER=false
diff --git a/api/server/csp.spec.js b/api/server/csp.spec.js
new file mode 100644
index 0000000000..6e2e4eef46
--- /dev/null
+++ b/api/server/csp.spec.js
@@ -0,0 +1,232 @@
+const fs = require('fs');
+const path = require('path');
+const request = require('supertest');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const mongoose = require('mongoose');
+
+/**
+ * Mirrors what a production `client/dist/index.html` actually contains: inline
+ * style, inline script, a module entry, and the module preloads Vite emits.
+ */
+const INDEX_HTML =
+ '
LibreChat' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+
+jest.mock('~/server/services/Config', () => ({
+ syncStaticTools: jest.fn().mockResolvedValue(undefined),
+ mergeAppTools: jest.fn().mockResolvedValue(undefined),
+ loadCustomConfig: jest.fn(() => Promise.resolve({})),
+ getAppConfig: jest.fn().mockResolvedValue({
+ paths: {
+ uploads: '/tmp',
+ dist: '/tmp/dist-csp',
+ fonts: '/tmp/fonts-csp',
+ assets: '/tmp/assets-csp',
+ },
+ fileStrategy: 'local',
+ imageOutputType: 'PNG',
+ }),
+ setCachedTools: jest.fn(),
+}));
+
+jest.mock('~/server/services/Agents/triggers', () => ({
+ initializeAgentTriggerService: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('~/server/services/Schedules', () => ({
+ initializeScheduleEngine: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('~/app/clients/tools', () => ({
+ createOpenAIImageTools: jest.fn(() => []),
+ createYouTubeTools: jest.fn(() => []),
+ manifestToolMap: {},
+ toolkits: [],
+}));
+
+jest.mock('~/config', () => ({
+ createMCPServersRegistry: jest.fn(),
+ createMCPManager: jest.fn().mockResolvedValue({
+ getAppToolFunctions: jest.fn().mockResolvedValue({}),
+ }),
+}));
+
+jest.mock(
+ '@librechat/api/telemetry',
+ () => ({
+ initializeTelemetry: jest.fn(() => ({
+ enabled: false,
+ status: 'disabled',
+ shutdown: jest.fn(),
+ })),
+ telemetryMiddleware: jest.fn((_req, _res, next) => next()),
+ telemetryErrorMiddleware: jest.fn((err, _req, _res, next) => next(err)),
+ }),
+ { virtual: true },
+);
+
+describe('Content Security Policy', () => {
+ jest.setTimeout(30_000);
+
+ let mongoServer;
+ let app;
+
+ const originalReadFileSync = fs.readFileSync;
+
+ beforeAll(async () => {
+ fs.readFileSync = function (filepath, options) {
+ if (filepath.includes('index.html')) {
+ return INDEX_HTML;
+ }
+ return originalReadFileSync(filepath, options);
+ };
+
+ for (const dir of ['/tmp/dist-csp', '/tmp/fonts-csp', '/tmp/assets-csp']) {
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ }
+ fs.writeFileSync(path.join('/tmp/dist-csp', 'index.html'), INDEX_HTML);
+
+ mongoServer = await MongoMemoryServer.create();
+ process.env.MONGO_URI = mongoServer.getUri();
+ process.env.PORT = '0';
+
+ /* Read once at startup, so they must be set before the server module loads. */
+ process.env.CSP_ENABLED = 'true';
+ process.env.CSP_REPORT_ONLY = 'false';
+ process.env.CSP_CONNECT_SRC_EXTRA = 'https://telemetry.example.com';
+ /* A cacheable override that CSP must refuse for the shell. */
+ process.env.INDEX_CACHE_CONTROL = 'public, max-age=3600';
+
+ app = require('~/server');
+ await healthCheckPoll(app);
+ });
+
+ afterAll(async () => {
+ fs.readFileSync = originalReadFileSync;
+ delete process.env.CSP_ENABLED;
+ delete process.env.CSP_REPORT_ONLY;
+ delete process.env.CSP_CONNECT_SRC_EXTRA;
+ delete process.env.INDEX_CACHE_CONTROL;
+ await mongoServer.stop();
+ await mongoose.disconnect();
+ });
+
+ it('sends an enforcing policy whose nonce matches the served scripts', async () => {
+ const response = await request(app).get('/');
+ const csp = response.headers['content-security-policy'];
+ const nonce = csp?.match(/script-src 'nonce-([^']+)'/)?.[1];
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-security-policy-report-only']).toBeUndefined();
+ expect(nonce).toBeTruthy();
+ expect(response.text).toContain(``);
+ expect(response.text).toContain(`',
+ '',
+ ].join('');
+
+ expect(applyCspNonce(html, 'abc123')).toBe(
+ [
+ '',
+ '',
+ '',
+ ].join(''),
+ );
+ });
+
+ it('replaces a stale nonce rather than preserving it', () => {
+ const html = '';
+
+ expect(applyCspNonce(html, 'abc123')).toBe('');
+ expect(applyCspNonce(html, 'abc123')).not.toContain('from-the-build');
+ });
+
+ it('stamps module preloads, which strict-dynamic does not cover', () => {
+ const html = [
+ '',
+ '',
+ '',
+ '',
+ ].join('');
+
+ expect(applyCspNonce(html, 'abc123')).toBe(
+ [
+ '',
+ '',
+ '',
+ '',
+ ].join(''),
+ );
+ });
+
+ it('returns the html untouched without a nonce', () => {
+ const html = '';
+ expect(applyCspNonce(html, '')).toBe(html);
+ });
+});
diff --git a/packages/api/src/security/csp.ts b/packages/api/src/security/csp.ts
new file mode 100644
index 0000000000..11c5a1dbee
--- /dev/null
+++ b/packages/api/src/security/csp.ts
@@ -0,0 +1,308 @@
+import { randomBytes } from 'crypto';
+import { logger } from '@librechat/data-schemas';
+import { parseEnvSwitch } from './env';
+import { isEnabled } from '../utils';
+
+/** Split point for the per-request nonce. Randomized so no env value can collide. */
+const NONCE_SLOT = `__csp_nonce_${randomBytes(8).toString('hex')}__`;
+
+const DIRECTIVE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
+/** `` is in here because module preloads are fetched under `script-src`. */
+const NONCEABLE_TAG_PATTERN = /<(script|link)\b([^>]*)>/gi;
+const NONCE_ATTRIBUTE_PATTERN = /\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
+const REL_PATTERN = /\srel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
+const AS_SCRIPT_PATTERN = /\sas\s*=\s*(?:"script"|'script'|script\b)/i;
+
+type CspDirective = [string, string[]];
+
+/** Precomputed once at startup; only the nonce varies per response. */
+export interface CspPolicy {
+ headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only';
+ prefix: string;
+ suffix: string;
+}
+
+export interface CspResponse {
+ headerName: CspPolicy['headerName'];
+ headerValue: string;
+ nonce: string;
+}
+
+const SOURCE_EXTRA_ENV: Record = {
+ 'default-src': 'CSP_DEFAULT_SRC_EXTRA',
+ 'script-src': 'CSP_SCRIPT_SRC_EXTRA',
+ 'style-src': 'CSP_STYLE_SRC_EXTRA',
+ 'img-src': 'CSP_IMG_SRC_EXTRA',
+ 'font-src': 'CSP_FONT_SRC_EXTRA',
+ 'connect-src': 'CSP_CONNECT_SRC_EXTRA',
+ 'media-src': 'CSP_MEDIA_SRC_EXTRA',
+ 'frame-src': 'CSP_FRAME_SRC_EXTRA',
+ 'worker-src': 'CSP_WORKER_SRC_EXTRA',
+ 'form-action': 'CSP_FORM_ACTION_EXTRA',
+};
+
+function splitSourceList(value: string | undefined): string[] {
+ if (!value) {
+ return [];
+ }
+ return value
+ .split(/[,\s]+/)
+ .map((source) => source.trim())
+ .filter(Boolean);
+}
+
+/**
+ * Only an explicitly recognized false value enforces. A typo or an unrecognized
+ * truthy spelling stays report-only, so a config slip cannot turn a rollout into
+ * a blocked SPA.
+ */
+function isReportOnly(env: NodeJS.ProcessEnv): boolean {
+ return parseEnvSwitch('CSP_REPORT_ONLY', env.CSP_REPORT_ONLY, true);
+}
+
+/**
+ * `'strict-dynamic'` makes browsers ignore every host source in `script-src`, so it
+ * cannot coexist with operator-supplied script hosts. When extras are configured we
+ * drop it and let the (now honored) `'self'` plus those hosts govern script loading.
+ */
+function scriptSources(scriptExtras: string[], allowWasm: boolean): string[] {
+ /* 'wasm-unsafe-eval' permits WebAssembly compilation without permitting eval();
+ * the HEIC upload path (client/src/utils/heicConverter.ts -> heic-to) needs it. */
+ const wasm = allowWasm ? ["'wasm-unsafe-eval'"] : [];
+ if (scriptExtras.length === 0) {
+ return [`'nonce-${NONCE_SLOT}'`, "'strict-dynamic'", ...wasm, "'self'"];
+ }
+ logger.info(
+ "[CSP] CSP_SCRIPT_SRC_EXTRA is set; omitting 'strict-dynamic' so the configured script hosts take effect.",
+ );
+ return [`'nonce-${NONCE_SLOT}'`, ...wasm, "'self'"];
+}
+
+/**
+ * Styles intentionally carry no nonce. A nonce in `style-src` makes browsers ignore
+ * `'unsafe-inline'`, which would block every `