diff --git a/docs/adr/0011-web-login-sessions.md b/docs/adr/0011-web-login-sessions.md
index 27c315912321191fa861df824bee5d708ebc7d46..e19a35ef3ffabe57a7609942dce44d06b706c621 100644
--- a/docs/adr/0011-web-login-sessions.md
+++ b/docs/adr/0011-web-login-sessions.md
@@ -19,8 +19,10 @@
 
 The server verifies the origin, username, key fingerprint, time, namespace,
 signature algorithm, and signature. The user can paste the envelope or upload
-the signature file. In one SQLite transaction, the server consumes the nonce
-and creates a seven-day session. The response stores an opaque session
+the signature file. An HTML form changes challenge line endings to CRLF. The
+HTTP interface changes these line endings back to LF before signature
+verification. In one SQLite transaction, the server consumes the nonce and
+creates a seven-day session. The response stores an opaque session
 token in an `HttpOnly` cookie and a CSRF token in a second cookie. Both cookies
 use `SameSite=Strict`. HTTPS responses also use `Secure`. SQLite stores only
 SHA-256 hashes of both values.
@@ -57,8 +59,9 @@
 between issue and verification, rejects challenge replay and a bad CSRF token,
 checks the stored hashes, invalidates a session after key addition, and tests
 the function that ends all sessions. The executable test completes login and
-logout through HTTP. It rejects an incorrect upload content type and malformed
-multipart content. It also confirms CSRF rejection and session invalidation.
+logout through HTTP with browser CRLF line endings. It rejects an incorrect
+upload content type and malformed multipart content. It also confirms CSRF
+rejection and session invalidation.
 
 ## Consequences
 
diff --git a/src/http/mod.rs b/src/http/mod.rs
index 4755eea70bce12dfcfe7191364c0974cf7c91c63..0a4221f4d548bc43090fd28b9e4ee16a1c753918 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -775,6 +775,7 @@
     login_csrf: String,
 ) -> Response {
     let display_username = username.clone();
+    let challenge = normalize_browser_newlines(challenge);
     let secure = state.secure_cookies;
     let correlation_id = request_id.to_owned();
     let result = login_job(state, move |login| {
@@ -827,6 +828,14 @@
             &display_username,
             "The signature is not valid or the challenge has expired.",
         ),
+    }
+}
+
+fn normalize_browser_newlines(value: String) -> String {
+    if value.contains("\r\n") {
+        value.replace("\r\n", "\n")
+    } else {
+        value
     }
 }
 
diff --git a/tests/serve.rs b/tests/serve.rs
index 9fba7cee6a12b5e7a0ca1b6601adbaf5d36e8060..aa9ce9ead824052cf011ab76f7e5d8d619fac673 100644
--- a/tests/serve.rs
+++ b/tests/serve.rs
@@ -177,6 +177,8 @@
         "</textarea>",
     );
     let signature = sign_challenge(instance.path(), &private_key, challenge);
+    let browser_challenge = challenge.replace('\n', "\r\n");
+    let browser_signature = signature.replace('\n', "\r\n");
     let login_csrf_cookies = response_cookies(&login_challenge);
     let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
     let rejected_login = http_form_with_headers(
@@ -185,8 +187,8 @@
         &[
             ("username", "alice"),
             ("public-key", public_key.trim()),
-            ("challenge", challenge),
-            ("signature", &signature),
+            ("challenge", &browser_challenge),
+            ("signature", &browser_signature),
             ("login-csrf", &"0".repeat(64)),
         ],
         &[("Cookie", &login_csrf_cookies)],
@@ -199,8 +201,8 @@
         &[
             ("username", "alice"),
             ("public-key", public_key.trim()),
-            ("challenge", challenge),
-            ("signature", &signature),
+            ("challenge", &browser_challenge),
+            ("signature", &browser_signature),
             ("login-csrf", login_csrf),
         ],
         &[("Cookie", &login_csrf_cookies)],
@@ -265,6 +267,7 @@
         "</textarea>",
     );
     let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
+    let browser_upload_challenge = upload_challenge.replace('\n', "\r\n");
     let upload_csrf_cookies = response_cookies(&upload_challenge_page);
     let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
     let wrong_upload_type = http_form_with_headers(
@@ -288,7 +291,7 @@
         &[
             ("username", "alice"),
             ("public-key", public_key.trim()),
-            ("challenge", upload_challenge),
+            ("challenge", &browser_upload_challenge),
             ("signature-file", &upload_signature),
             ("login-csrf", upload_csrf),
         ],
