diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 792233908..1cdda583c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,6 +6,9 @@ repos:
     hooks:
       - id: trailing-whitespace
       - id: end-of-file-fixer
+        # do not fix snapshot files because these are golden
+        # files which need to match the test output exactly.
+        exclude: /snapshots/
       # Suppress check-yaml because of false error:
       # "could not determine a constructor for the tag 'tag:yaml.org,2002:python/name:material.extensions.emoji.twemoji'"
       # - id: check-yaml
diff --git a/mesop/server/static_file_serving.py b/mesop/server/static_file_serving.py
index c19c38e40..9edfc791c 100644
--- a/mesop/server/static_file_serving.py
+++ b/mesop/server/static_file_serving.py
@@ -1,10 +1,12 @@
 import gzip
 import io
 import os
+import secrets
+from collections import OrderedDict
 from io import BytesIO
 from typing import Any, Callable
 
-from flask import Flask, send_file
+from flask import Flask, Response, g, send_file
 from werkzeug.security import safe_join
 
 from mesop.utils.runfiles import get_runfile_location
@@ -26,21 +28,24 @@ def get_path(path: str):
     return get_runfile_location(safe_path)
 
   def retrieve_index_html() -> io.BytesIO | str:
-    if livereload_script_url:
-      file_path = get_path("index.html")
-      with open(file_path) as file:
-        lines = file.readlines()
+    file_path = get_path("index.html")
+    with open(file_path) as file:
+      lines = file.readlines()
 
-      for i, line in enumerate(lines):
-        if line.strip() == "<!-- Inject script (if needed) -->":
-          lines[i] = f'<script src="{livereload_script_url}"></script>\n'
+    for i, line in enumerate(lines):
+      if "$$INSERT_CSP_NONCE$$" in line:
+        lines[i] = lines[i].replace("$$INSERT_CSP_NONCE$$", g.csp_nonce)
+      if (
+        livereload_script_url
+        and line.strip() == "<!-- Inject script (if needed) -->"
+      ):
+        lines[i] = f'<script src="{livereload_script_url}"></script>\n'
 
-      # Create a BytesIO object from the modified lines
-      modified_file_content = "".join(lines)
-      binary_file = io.BytesIO(modified_file_content.encode())
+    # Create a BytesIO object from the modified lines
+    modified_file_content = "".join(lines)
+    binary_file = io.BytesIO(modified_file_content.encode())
 
-      return binary_file
-    return get_path("index.html")
+    return binary_file
 
   @app.route("/")
   def serve_root():
@@ -55,6 +60,65 @@ def serve_file(path: str):
     else:
       return send_file(retrieve_index_html(), download_name="index.html")
 
+  @app.before_request
+  def generate_nonce():
+    g.csp_nonce = secrets.token_urlsafe(16)
+
+  @app.after_request
+  def add_security_headers(response: Response):
+    # Set X-Content-Type-Options header to prevent MIME type sniffing
+    response.headers["X-Content-Type-Options"] = "nosniff"
+
+    # Set Strict-Transport-Security header to enforce HTTPS
+    # All present and future subdomains will be HTTPS for a max-age of 1 year.
+    # This blocks access to pages or subdomains that can only be served over HTTP.
+    # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security#examples
+    response.headers[
+      "Strict-Transport-Security"
+    ] = "max-age=31536000; includeSubDomains"
+
+    # Technically order doesn't matter, but it's useful for testing
+    # https://stackoverflow.com/a/77850553
+    csp = OrderedDict(
+      {
+        "default-src": "'self'",
+        "font-src": "fonts.gstatic.com",
+        # TODO: make frame-ancestors stricter
+        # https://github.com/google/mesop/issues/271
+        "frame-ancestors": "'self' https:",
+        # Mesop app developers should be able to iframe other sites.
+        "frame-src": "'self' https:",
+        # Mesop app developers should be able to load images and media from various origins.
+        "img-src": "'self' data: https:",
+        "media-src": "'self' data: https:",
+        "style-src": f"'self' 'nonce-{g.csp_nonce}' fonts.googleapis.com",
+        # Need 'unsafe-inline' because we apply inline styles for our components.
+        # This is also used by Angular for animations:
+        # https://github.com/angular/angular/pull/55260
+        "style-src-attr": "'unsafe-inline'",
+        "script-src": f"'self' 'nonce-{g.csp_nonce}'",
+        "trusted-types": "angular",
+        "require-trusted-types-for": "'script'",
+      }
+    )
+    # Set Content-Security-Policy header to restrict resource loading
+    # Based on https://angular.io/guide/security#content-security-policy
+    response.headers["Content-Security-Policy"] = "; ".join(
+      [key + " " + value for key, value in csp.items()]
+    )
+
+    # Set Referrer-Policy header to control referrer information
+    # Recommended by https://web.dev/articles/referrer-best-practices
+    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+
+    # If we've set Cache-Control earlier, respect those.
+    if "Cache-Control" not in response.headers:
+      # no-store ensures that resources are never cached.
+      # https://web.dev/articles/http-cache#request-headers
+      response.headers["Cache-Control"] = "no-store"
+
+    return response
+
 
 def is_file_path(path: str) -> bool:
   _, last_segment = os.path.split(path)
diff --git a/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt
new file mode 100644
index 000000000..fad4538ea
--- /dev/null
+++ b/mesop/tests/e2e/snapshots/web_security_test.ts_csp.txt
@@ -0,0 +1,11 @@
+default-src 'self'
+font-src fonts.gstatic.com
+frame-ancestors 'self' https:
+frame-src 'self' https:
+img-src 'self' data: https:
+media-src 'self' data: https:
+style-src 'self' 'nonce-{{NONCE}}' fonts.googleapis.com
+style-src-attr 'unsafe-inline'
+script-src 'self' 'nonce-{{NONCE}}'
+trusted-types angular
+require-trusted-types-for 'script'
\ No newline at end of file
diff --git a/mesop/tests/e2e/web_security_test.ts b/mesop/tests/e2e/web_security_test.ts
new file mode 100644
index 000000000..cafb6af8a
--- /dev/null
+++ b/mesop/tests/e2e/web_security_test.ts
@@ -0,0 +1,17 @@
+// http://localhost:32123/plot
+import {test, expect} from '@playwright/test';
+
+const EXPECTED_CSP =
+  "default-src 'self'; font-src fonts.gstatic.com; frame-ancestors 'self' https:; frame-src 'self' https:; img-src 'self' data: https:; media-src 'self' data: https:; style-src 'self' 'nonce-{{NONCE}}' fonts.googleapis.com; style-src-attr 'unsafe-inline'; script-src 'self' 'nonce-{{NONCE}}'; trusted-types angular; require-trusted-types-for 'script'";
+
+test('ensure web security best practices are followed', async ({page}) => {
+  const response = await page.goto('/');
+  const csp = response?.headers()['content-security-policy'];
+  expect(
+    csp
+      // nonce is randomly generated so we need to replace it with a stable string.
+      ?.replace(/nonce-\w+/g, 'nonce-{{NONCE}}')
+      // A bit of formatting to make it easier to read.
+      .replace(/; /g, '\n'),
+  ).toMatchSnapshot('csp.txt');
+});
diff --git a/mesop/web/src/app/dev/index.html b/mesop/web/src/app/dev/index.html
index 5145b8d81..86102fa3e 100644
--- a/mesop/web/src/app/dev/index.html
+++ b/mesop/web/src/app/dev/index.html
@@ -24,7 +24,9 @@
     <link rel="stylesheet" href="./styles.css" />
   </head>
   <body>
-    <mesop-editor-app>Loading...</mesop-editor-app>
+    <mesop-editor-app ngCspNonce="$$INSERT_CSP_NONCE$$"
+      >Loading...</mesop-editor-app
+    >
     <script src="zone.js/dist/zone.js"></script>
     <script src="bundles/main.js" type="module"></script>
   </body>
diff --git a/mesop/web/src/app/editor/index.html b/mesop/web/src/app/editor/index.html
index 8f37825df..4605b0fa7 100644
--- a/mesop/web/src/app/editor/index.html
+++ b/mesop/web/src/app/editor/index.html
@@ -25,7 +25,9 @@
     <link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
   </head>
   <body>
-    <mesop-editor-app>Loading...</mesop-editor-app>
+    <mesop-editor-app ngCspNonce="$$INSERT_CSP_NONCE$$"
+      >Loading...</mesop-editor-app
+    >
     <!-- Inject script (if needed) -->
     <script src="zone.js/dist/zone.js"></script>
     <script src="editor_bundle/bundle.js" type="module"></script>
diff --git a/mesop/web/src/app/prod/index.html b/mesop/web/src/app/prod/index.html
index c3bd03678..a36d86988 100644
--- a/mesop/web/src/app/prod/index.html
+++ b/mesop/web/src/app/prod/index.html
@@ -25,7 +25,7 @@
     <link rel="shortcut icon" href="./favicon.ico" type="image/x-icon" />
   </head>
   <body>
-    <mesop-app>Loading...</mesop-app>
+    <mesop-app ngCspNonce="$$INSERT_CSP_NONCE$$">Loading...</mesop-app>
     <!-- Inject script (if needed) -->
     <script src="zone.js/dist/zone.js"></script>
     <script src="prod_bundle.js" type="module"></script>
diff --git a/playwright.config.ts b/playwright.config.ts
index 7492dd664..e3f71521a 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -18,6 +18,12 @@ const enableComponentTreeDiffs = process.env.ENABLE_COMPONENT_TREE_DIFFS
 export default defineConfig({
   timeout: process.env.CI ? 20000 : 10000, // Budget more time for CI since tests run slower there.
   testDir: '.',
+  // Use a custom snapshot path template because Playwright's default
+  // is platform-specific which isn't necessary for Mesop e2e tests
+  // which should be platform agnostic (we don't do screenshots; only textual diffs).
+  snapshotPathTemplate:
+    '{testDir}/{testFileDir}/snapshots/{testFileName}_{arg}{ext}',
+
   testMatch: 'e2e/*_test.ts',
   testIgnore: 'scripts/**',
   /* Run tests in files in parallel */