Engineering log
Client-level auth hardening: rate limiting, Turnstile, GDPR transparency (no WAF/Front Door)
Hardened the registration/login/account-recovery surface against automated abuse without a Web Application Firewall or CDN in front of the App Service - a deliberate cost decision, not an oversight. Enabled Better Auth's own database-backed rate limiter, added a self-built limiter for routes outside its router, added Cloudflare Turnstile bot protection, a stateless timing heuristic as an independent second signal, and a new /forget-password + /reset-password flow (email delivery itself is stubbed, since this project has no email infrastructure yet). Also closed a real UK GDPR transparency gap - /sign-up and /log-in collected data with no in-context privacy notice or link to /legal.
Decisions
- No WAF/Front Door was ever the plan here - Azure Front Door Premium and Application Gateway both carry a fixed monthly cost disproportionate to this project's traffic and its established cost-discipline pattern (ADR-0010). Considered but explicitly named as cost-prohibitive rather than technically unsuitable: Cloudflare Enterprise Bot Management, Akamai Bot Manager, DataDome.
- Read the installed better-auth/@better-auth/core source directly rather than trusting documentation prose: getIP does not naively trust the first or last X-Forwarded-For token - with no trustedProxies configured it only trusts a single-value header, and a multi-value chain falls through to a shared bucket rather than being misattributed. trustedProxies was left unset since Azure App Service's multitenant front-end has no small, stable CIDR range to list - meaning a bot sending one spoofed value is still trusted at face value, a gap deliberately covered by Turnstile and the timing heuristic instead of IP configuration.
- Better Auth's own rateLimit plugin enabled in every environment (defaults off outside production), storage: 'database' rather than in-memory - this is a single-instance App Service that restarts/redeploys, and an in-memory counter would silently reset on every one. Its own documented caveat that auth.api calls bypass rate limiting means routes calling getSession directly (two-factor/verify, account/export) needed a self-built limiter reusing the same table and key shape.
- Cloudflare Turnstile in Managed mode, not Invisible mode - Invisible requires referencing Cloudflare's own Turnstile Privacy Addendum in this project's privacy policy for no accuracy benefit over Managed mode's already-equivalent background checks. A full ICO-style Legitimate Interests Assessment (docs/gdpr/0001) was written for this processing rather than asserting the lawful basis without justification.
- Honeypot fields were explicitly rejected as obsolete (modern scraping frameworks trivially skip hidden fields via computed-style inspection), as was client-side device fingerprinting (builds a persistent cross-site identifier, a harder UK GDPR/PECR justification than Turnstile's purpose-bound check) and a hand-rolled proof-of-work challenge (Turnstile's Managed mode already does this).
- The stateless timing-token approach (HMAC-signed render timestamp, verified in auth.ts's hooks.before, confirmed by reading better-call's dispatch/context source that this runs before Better Auth's own per-endpoint schema stripping) was chosen over a server-issued nonce stored in the database specifically because it costs nothing beyond the HMAC computation and gives an independent, self-hosted second signal alongside Turnstile.
- PgBouncer was evaluated across three paths and adopted in none: Azure's managed :6432 endpoint requires a paid General Purpose/Memory Optimized SKU upgrade (contradicts ADR-0010's Burstable rationale); a self-hosted Azure Container Instance is not 'pennies' as sometimes claimed (~$15-20/month, comparable to the entire Postgres line item) and either needs new VNet plumbing or opens a second public attack surface; a free App Service sidecar requires containerizing the app first, a real prerequisite not done today. Immediate zero-cost mitigation instead: explicit pg.Pool bounds in db/client.ts.
- Access Restrictions were evaluated for both the main app (rejected - a public site with open registration, allow/deny would block real visitors not bots) and the SCM/Kudu site (rejected after confirming directly against Microsoft's AzureWebApp@1 task docs that its zipDeploy method deploys through that exact SCM endpoint, with no documented stable IP range for Azure DevOps's ephemeral hosted agents - denying it would break every future deployment).
- The new CSP header is deliberately narrow (script-src/frame-src/connect-src only, no default-src) and retains 'unsafe-inline' - this app has two real inline scripts (the Organization JSON-LD and the pre-hydration accessibility theme flash-prevention script) that a stricter policy would silently break without a nonce-based rework, a separate, larger effort than this slice.
- Chose to reuse the CD pipeline's own proven firewall-rule-open/Node-script/firewall-rule-close pattern for scheduled rate_limit pruning (a new Azure DevOps cron pipeline) rather than an Azure Automation runbook, after confirming Automation's sandboxed PowerShell runtime has no psql/Npgsql precedent in this repo and a Python runbook would need its own new module-import setup proven from scratch.
- TRUST_PROXY=true (an Express.js/express-rate-limit convention) does nothing for this stack - confirmed directly against both Better Auth's and Next.js's own documented options, neither of which reads that environment variable.
- After comparing against Cloudflare's own dashboard sign-up page, made the Turnstile widget theme-aware: it now reads the same devafusion-a11y-theme cookie ThemeSelector writes and maps this site's three named profiles to Turnstile's light/dark/auto vocabulary, rather than leaving it on theme: 'auto' (which only follows OS prefers-color-scheme and has no visibility into this site's own cookie-driven theme choice). Turnstile's public API has no fourth 'custom palette' option, so the tactical high-contrast profile still only gets Turnstile's stock dark chrome - documented as an accepted API limitation, not fixed further.
- Added a shared PasswordField show/hide toggle (matching Cloudflare's own dashboard UX) to SignUpForm, LogInForm and ResetPasswordForm - strictly outside this slice's original security/GDPR scope, but added in the same pass since all three forms were already being touched for Turnstile. DeleteAccountForm was deliberately left untouched as genuinely out of scope.
- Caught and fixed a real mobile-responsiveness gap: the Turnstile widget had never set the size option, defaulting to Turnstile's fixed ~300px 'normal' size rather than filling its container the way every other field in the form already does. Set size: 'flexible' (Cloudflare's own documented responsive mode).
- Also set appearance: 'interaction-only' rather than accepting Cloudflare's default ('always', visible from page load regardless of outcome) - keeps the form visually clean when Managed mode's background check passes silently, confirmed against Cloudflare's own docs that this only affects visibility, not whether the success callback fires.
Milestones
- auth.ts: added rateLimit (database storage), the captcha plugin (Cloudflare Turnstile, Managed mode), advanced.ipAddress config, hooks.before running the timing-token check, and a sendResetPassword callback stubbed to log a warning rather than silently pretending to deliver an email.
- New rateLimit/rate_limit table, CLI-generated (npx auth@latest generate) and merged into db/schema.ts per the same discipline ADR-0012 established; new migration drizzle/0001_brown_hemingway.sql.
- New features/auth/client-ip.ts (shared true-client-IP resolution, reusing @better-auth/core/utils/ip's own getIP), features/auth/form-timing-token.ts (HMAC-signed timing heuristic), and features/auth/rate-limit.ts (self-built limiter via a single atomic UPDATE...RETURNING, constructor-injectable db for PGlite testing).
- Wired the self-built limiter into app/api/auth/two-factor/verify/route.ts and app/api/account/export/route.ts.
- New components/auth/turnstile-widget.tsx (hand-rolled, next/script-based - no new npm dependency for a single script tag + render() call) wired into SignUpForm, LogInForm, and the new ForgetPasswordForm.
- New /forget-password and /reset-password pages/forms completing the password-reset loop; /forget-password forced to dynamic rendering (export const dynamic = 'force-dynamic') after a real build failure showed Next.js would otherwise statically prerender it and bake in a single stale timing token for every visitor.
- Added a one-line UK GDPR transparency notice plus a /legal link directly below the submit button on /sign-up, /log-in and /forget-password - a real, confirmed gap distinct from the security work, since /legal already correctly documented the lawful basis/retention/rights but was unreachable from the point of data collection.
- Updated /legal with a new 'Bot protection (Cloudflare Turnstile)' section (processor disclosure, UK entity/DPO contact, a pre-declared conditional cookie note for Turnstile's pre-clearance mode even though it isn't enabled) and updated the 'What is not collected' section to name Turnstile alongside Google Analytics.
- New docs/adr/0014 (the full decision set) and docs/gdpr/0001 (a full ICO three-part-test Legitimate Interests Assessment for Turnstile) - the latter is a new document type/directory in this repo.
- Terraform: new Key Vault secret data sources (turnstile-secret-key-devafusion, turnstile-site-key-devafusion, form-timing-token-secret-devafusion), wired into web.tf's app_settings; a new scheduled pipeline (pipelines/cd/prune-rate-limit.yml, daily cron) reusing the existing CD pipeline's firewall+Node/pg pattern.
- next.config.ts: first-ever CSP header, deliberately scoped to Turnstile plus the already-present GA4 origins.
- db/client.ts: explicit pg.Pool bounds (max, idleTimeoutMillis, connectionTimeoutMillis).
- New components/auth/password-field.tsx (shared show/hide toggle) wired into SignUpForm, LogInForm and ResetPasswordForm.
- turnstile-widget.tsx: exported resolveTurnstileTheme and wired a useSyncExternalStore cookie read into the widget's render() call.
Validation
- Verified the account-enumeration concern directly against the installed better-auth package source rather than assuming: both /sign-in/email and /request-password-reset return identical generic responses regardless of whether the account exists, and the reset-password path deliberately simulates a dummy token generation and database lookup to keep response timing consistent too - no code fix was needed, only citing the finding in the ADR.
- Verified hooks.before genuinely runs before Better Auth's own per-endpoint zod body-stripping by reading better-call's dispatch.mjs/context.mjs source directly, confirming the raw formTimingToken field survives to the hook rather than being silently dropped.
- A real secret exposure occurred mid-session (a live PostgreSQL admin password was printed in plaintext by an unnecessary az cli query) and was rotated before any further work continued, per root AGENTS.md's Zero Hardcoded Secrets rule.
- npm run typecheck, npm run lint (zero warnings), npm run build, and npm run test:unit (15 files, 77 tests, including a real PGlite-backed atomicity suite for the self-built rate limiter) all pass.
- terraform fmt -check and terraform validate both pass clean for every infrastructure change in this slice.
- Ran the Playwright visual regression spec against the pinned mcr.microsoft.com/playwright Docker image with --update-snapshots - passed against the existing committed home-page baseline with zero pixel-diff regeneration, confirming the new CSP header and GDPR notices on unrelated pages have no visual impact on the home page.
- The CSP header itself is flagged in the ADR as authored by reasoning about known script/beacon sources (Turnstile, GA4, the two existing inline scripts) rather than empirically verified against a live deployed preview's browser console - that manual check remains an explicit pre-merge follow-up.
- Re-ran npm run typecheck, npm run lint, npm run test:unit (16 files, 82 tests, including 5 new resolveTurnstileTheme cases) and npm run build after adding the theme-aware widget and password toggle - all pass. No visual regression re-check was needed: the only committed visual baseline covers the home page, which neither change touches.
- Added tests-e2e/sign-up.spec.ts against a real running server (npm run start via playwright.config.ts's webServer), using Cloudflare's own documented dummy Turnstile keys (1x00000000000000000000AA sitekey / 1x0000000000000000000000000000000AA secret key - the published 'always passes' pair, explicitly named by Cloudflare's own testing docs to cover Playwright) rather than mocking TurnstileWidget, since the unit suite already mocks it entirely and would never catch a real wiring regression between the client render() call and the server-side captcha plugin. Running this for real caught two genuine bugs before they shipped: (1) a stale dev server left running from earlier in the session was serving a build missing FORM_TIMING_TOKEN_SECRET, masking the real test outcome until it was killed; (2) getByLabel('Password') was a strict-mode violation matching both the password input and PasswordField's 'Show password' toggle button (Playwright's getByLabel does substring matching by default) - fixed with { exact: true }. The account-creation test is gated behind TEST_DB_ACTIONS (src/web/__tests__/AGENTS.md's documented toggle), since pipelines/ci/web.yml's E2ETests job has no PostgreSQL sandbox wired up yet; the password-toggle test needs no database and always runs.