diff --git a/docs/api-reference.md b/docs/api-reference.md
index 63c587f..653e7fe 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -103,8 +103,7 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{
"email": "string",
"password": "string",
- "totpCode": "string (optional, required if 2FA is enabled)",
- "turnstileToken": "string"
+ "totpCode": "string (optional, required if 2FA is enabled)"
}
```
- **Response:**
diff --git a/docs/authentication.md b/docs/authentication.md
index 3d29e43..64283a4 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection)
-Applied to `/api/auth/login`, `/register`, `/forgot-password` when
+Applied to `/api/auth/register` and `/api/auth/forgot-password` when
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
+`/api/auth/login` is deliberately **not** gated: the widget could not
+reliably complete a challenge inside the Capacitor WebView, which locked
+mobile users out of the app. Login is covered instead by its per-IP rate
+limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
+
+The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
+the first time their form becomes visible — Turnstile does not reliably
+complete a challenge inside a `display:none` container, and both forms start
+hidden. Tokens are captured from the render callback, not read back out of
+the injected `[name="cf-turnstile-response"]` input.
+
+Note that the site key is currently **hardcoded** in `public/index.html`.
+`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
+
## Encryption at rest
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from
diff --git a/e2e/tests/session-persistence.spec.js b/e2e/tests/session-persistence.spec.js
index b6e9c24..9fd731c 100644
--- a/e2e/tests/session-persistence.spec.js
+++ b/e2e/tests/session-persistence.spec.js
@@ -2,14 +2,15 @@
// SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill.
//
-// The UI's login form is gated by a Cloudflare Turnstile token
-// whose site key is hardcoded in index.html, which can't be
-// completed in the e2e container (Turnstile rejects the non-prod
-// origin). So the test does a programmatic logout (clear the
-// ped_auth cookie, same effect server-side as clicking Logout)
-// followed by a fresh programmatic login — this exercises the
-// same localStorage persistence path a real logout/login would,
-// without depending on the bot challenge.
+// The test does a programmatic logout (clear the ped_auth cookie,
+// same effect server-side as clicking Logout) followed by a fresh
+// programmatic login. This exercises the same localStorage
+// persistence path a real logout/login would.
+//
+// (Historically this was a workaround for the Turnstile challenge on
+// the login form, which could not be completed in the e2e container.
+// Login is no longer gated, but driving it programmatically keeps
+// the test focused on persistence rather than form mechanics.)
// ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
diff --git a/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java b/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java
index 4934d10..2f3a725 100644
--- a/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java
+++ b/mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java
@@ -15,6 +15,7 @@ import android.print.PrintDocumentAdapter;
import android.print.PrintManager;
import android.provider.MediaStore;
import android.util.Base64;
+import android.webkit.CookieManager;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
import android.webkit.WebViewClient;
@@ -47,6 +48,9 @@ public class MainActivity extends BridgeActivity {
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
}
+ // Allow the Cloudflare Turnstile iframe to use storage.
+ setupThirdPartyCookies();
+
// Setup WebView mic permission granting
setupWebViewPermissions();
@@ -60,6 +64,26 @@ public class MainActivity extends BridgeActivity {
setupFileBridge();
}
+ // ── Third-Party Cookies ────────────────────────────────────
+ //
+ // Android WebView blocks third-party cookies by default (unlike Chrome,
+ // which still allows them for now). Cloudflare Turnstile runs inside a
+ // cross-origin iframe from challenges.cloudflare.com and needs its own
+ // storage to run and persist a challenge — without this the widget
+ // silently stalls or errors and never emits a token, so registration and
+ // password reset are impossible from inside the app.
+ //
+ // This is scoped to our own WebView, which only ever loads the PedScribe
+ // origin (see allowNavigation in capacitor.config.json), so it is not a
+ // general relaxation of the app's cookie policy.
+
+ private void setupThirdPartyCookies() {
+ WebView webView = this.bridge.getWebView();
+ CookieManager cookieManager = CookieManager.getInstance();
+ cookieManager.setAcceptCookie(true);
+ cookieManager.setAcceptThirdPartyCookies(webView, true);
+ }
+
// ── WebView Microphone Permission ──────────────────────────
private void setupWebViewPermissions() {
diff --git a/public/index.html b/public/index.html
index 22154df..591fb8a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -11,7 +11,11 @@
-
+
+
@@ -70,7 +74,6 @@
-