diff --git a/changelog.d/18090.feature b/changelog.d/18090.feature
new file mode 100644
index 0000000000000000000000000000000000000000..343e2f45ebc025c91fdadec539346f728a876d51
--- /dev/null
+++ b/changelog.d/18090.feature
@@ -0,0 +1 @@
+Add `form_secret_path` config option.
\ No newline at end of file
diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md
index 8523c5f65fa615f2f72c9ae0d35c97ffe8fd521d..f5a5aa2eb44ea913fb08140e2e6f138ff0eaf3ff 100644
--- a/docs/usage/configuration/config_documentation.md
+++ b/docs/usage/configuration/config_documentation.md
@@ -3238,6 +3238,22 @@ Example configuration:
 ```yaml
 form_secret: <PRIVATE STRING>
 ```
+---
+### `form_secret_path`
+
+An alternative to [`form_secret`](#form_secret):
+allows the secret to be specified in an external file.
+
+The file should be a plain text file, containing only the secret.
+Synapse reads the secret from the given file once at startup.
+
+Example configuration:
+```yaml
+form_secret_path: /path/to/secrets/file
+```
+
+_Added in Synapse 1.125.0._
+
 ---
 ## Signing Keys
 Config options relating to signing keys
diff --git a/synapse/config/key.py b/synapse/config/key.py
index 6f99f39e817192a959357c5e13a45147f5c9c901..337f98dbc10cbab688abdc02e1420bcdf42da291 100644
--- a/synapse/config/key.py
+++ b/synapse/config/key.py
@@ -96,6 +96,11 @@ Conflicting options 'macaroon_secret_key' and 'macaroon_secret_key_path' are
 both defined in config file.
 """
 
+CONFLICTING_FORM_SECRET_OPTS_ERROR = """\
+Conflicting options 'form_secret' and 'form_secret_path' are both defined in
+config file.
+"""
+
 logger = logging.getLogger(__name__)
 
 
@@ -201,12 +206,19 @@ class KeyConfig(Config):
 
         # a secret which is used to calculate HMACs for form values, to stop
         # falsification of values
-        self.form_secret = config.get("form_secret", None)
-        if self.form_secret and not allow_secrets_in_config:
+        form_secret = config.get("form_secret", None)
+        if form_secret and not allow_secrets_in_config:
             raise ConfigError(
                 "Config options that expect an in-line secret as value are disabled",
                 ("form_secret",),
             )
+        form_secret_path = config.get("form_secret_path", None)
+        if form_secret_path:
+            if form_secret:
+                raise ConfigError(CONFLICTING_FORM_SECRET_OPTS_ERROR)
+            self.form_secret = read_file(form_secret_path, "form_secret_path").strip()
+        else:
+            self.form_secret = form_secret
 
     def generate_config_section(
         self,
diff --git a/tests/config/test_load.py b/tests/config/test_load.py
index 18fb2e0c2c2e8a9a01d7cb253cad7cacbd7cfb60..14dfa6e59d1b83399eba3c60a0097cb03eae316d 100644
--- a/tests/config/test_load.py
+++ b/tests/config/test_load.py
@@ -138,6 +138,7 @@ class ConfigLoadingFileTestCase(ConfigFileTestCase):
             "turn_shared_secret_path: /does/not/exist",
             "registration_shared_secret_path: /does/not/exist",
             "macaroon_secret_key_path: /does/not/exist",
+            "form_secret_path: /does/not/exist",
             "experimental_features:\n  msc3861:\n    client_secret_path: /does/not/exist",
             "experimental_features:\n  msc3861:\n    admin_token_path: /does/not/exist",
             *["redis:\n  enabled: true\n  password_path: /does/not/exist"]
@@ -165,6 +166,10 @@ class ConfigLoadingFileTestCase(ConfigFileTestCase):
                 "macaroon_secret_key_path: {}",
                 lambda c: c.key.macaroon_secret_key,
             ),
+            (
+                "form_secret_path: {}",
+                lambda c: c.key.form_secret.encode("utf-8"),
+            ),
             (
                 "experimental_features:\n  msc3861:\n    client_secret_path: {}",
                 lambda c: c.experimental.msc3861.client_secret().encode("utf-8"),
@@ -186,7 +191,7 @@ class ConfigLoadingFileTestCase(ConfigFileTestCase):
         self, config_line: str, get_secret: Callable[[RootConfig], str]
     ) -> None:
         self.generate_config_and_remove_lines_containing(
-            ["registration_shared_secret", "macaroon_secret_key"]
+            ["form_secret", "macaroon_secret_key", "registration_shared_secret"]
         )
         with tempfile.NamedTemporaryFile(buffering=0) as secret_file:
             secret_file.write(b"53C237")