Skip to content

feat(auth): add Turnstile captcha + harmony disposable email blocking#3699

Merged
waleedlatif1 merged 10 commits intostagingfrom
feat/turnstile
Mar 21, 2026
Merged

feat(auth): add Turnstile captcha + harmony disposable email blocking#3699
waleedlatif1 merged 10 commits intostagingfrom
feat/turnstile

Conversation

@waleedlatif1
Copy link
Collaborator

@waleedlatif1 waleedlatif1 commented Mar 21, 2026

Summary

  • Add Cloudflare Turnstile (invisible, execute-on-submit) to signup and login forms for bot protection
  • Add better-auth-harmony plugin to block 55K+ disposable email domains and normalize emails
  • Both features are fully conditional: Turnstile requires TURNSTILE_SECRET_KEY + NEXT_PUBLIC_TURNSTILE_SITE_KEY, harmony requires SIGNUP_EMAIL_VALIDATION_ENABLED=true
  • Without env vars, behavior is identical to before — zero impact on self-hosted deployments

Changes

  • auth.ts: Add captcha() and emailHarmony() plugins (conditional), remove custom disposable email check
  • signup-form.tsx / login-form.tsx: Add invisible Turnstile with execute-on-submit pattern (reset → execute → await token → send via x-captcha-response header)
  • feature-flags.ts: Add isSignupEmailValidationEnabled flag
  • env.ts: Add TURNSTILE_SECRET_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEY, SIGNUP_EMAIL_VALIDATION_ENABLED
  • next.config.ts: Add better-auth-harmony to transpilePackages (required for validator.js ESM)
  • schema.ts: Add normalized_email column to user table (required by harmony)
  • validation.ts: Simplified back to inline 24-domain list for client-side fast-fail (server-side blocking now handled by harmony)

New env vars

Variable Required Description
TURNSTILE_SECRET_KEY No Cloudflare Turnstile secret key
NEXT_PUBLIC_TURNSTILE_SITE_KEY No Cloudflare Turnstile site key
SIGNUP_EMAIL_VALIDATION_ENABLED No Enable harmony disposable email blocking

Test plan

  • Verify signup/login works without any new env vars (no Turnstile widget, no harmony)
  • Set Turnstile keys → verify invisible captcha executes on submit, no visible widget
  • Set SIGNUP_EMAIL_VALIDATION_ENABLED=true → verify disposable email signup is blocked
  • Test with disposable email (e.g. mailinator.com) → should be rejected by harmony
  • Run database migration for normalized_email column

@vercel
Copy link

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 21, 2026 6:23pm

Request Review

@cursor
Copy link

cursor bot commented Mar 21, 2026

PR Summary

Medium Risk
Touches the login/signup authentication paths by adding optional captcha enforcement and changing server-side disposable-email blocking behavior, which can affect user access if misconfigured. Also introduces a DB migration and new env/feature flags that must be set consistently across deployments.

Overview
Adds optional bot protection to email sign-in and sign-up by running an invisible Cloudflare Turnstile challenge on submit and forwarding the token via the x-captcha-response header when NEXT_PUBLIC_TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY are set.

Replaces the custom server-side disposable-email/MX validation with better-auth-harmony-based disposable email blocking (gated by SIGNUP_EMAIL_VALIDATION_ENABLED) and introduces a user.normalized_email column + unique constraint to support normalization. Client-side email validation is simplified to a small inline disposable-domain list, and config/docs are updated to include the new env vars and package dependencies.

Written by Cursor Bugbot for commit 5ae1d9e. Configure here.

@waleedlatif1 waleedlatif1 changed the base branch from main to staging March 21, 2026 17:24
waleedlatif1 and others added 2 commits March 21, 2026 10:26
… feature flag

- Switch Turnstile to execution: 'execute' mode so challenge runs on
  form submit (fresh token every time, no expiry issues)
- Make emailHarmony conditional via SIGNUP_EMAIL_VALIDATION_ENABLED
  feature flag so self-hosted users can opt out
- Add isSignupEmailValidationEnabled to feature-flags.ts following
  existing pattern
- Add better-auth-harmony to Next.js transpilePackages (required for
  validator.js ESM compatibility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR adds two independent, opt-in security layers to the auth flows: invisible Cloudflare Turnstile captcha (bot protection) and better-auth-harmony disposable-email blocking (55K+ domains). Both are fully gated behind environment variables, preserving identical behaviour for self-hosted deployments that don't set them.

Key changes and observations:

  • Turnstile integration — both forms now use the reset()execute()getResponsePromise(15_000) pattern, addressing the previous timeout and dangling-timer concerns cleanly via the library's own API.
  • Forgot-password UX improvement — the old useEffect keyboard listener was removed and replaced with a native <form onSubmit> wrapper, which is a strictly better pattern.
  • emailHarmony plugin — replaces the removed isDisposableEmailFull / isDisposableMxBackend calls with a server-side plugin that covers 10x more domains; gated on SIGNUP_EMAIL_VALIDATION_ENABLED to avoid breaking deployments that haven't run the normalized_email migration yet.
  • normalized_email migration — adds a nullable UNIQUE column; safe for PostgreSQL since existing NULL rows do not violate the constraint.
  • Turnstile key pairingTURNSTILE_SECRET_KEY (server) and NEXT_PUBLIC_TURNSTILE_SITE_KEY (client) must be set together or left both unset. Setting only the server key causes the captcha() plugin to require a token that the client never sends, silently breaking all email auth. A startup guard is recommended.

Confidence Score: 4/5

  • Safe to merge after adding a startup guard for the paired Turnstile env vars; all other changes are well-implemented and the previous timeout/cleanup concerns are fully resolved.
  • The implementation is clean and the iterative review has resolved the timeout, timer-cleanup, and captcha-promise patterns. The one remaining concrete concern — that setting TURNSTILE_SECRET_KEY without NEXT_PUBLIC_TURNSTILE_SITE_KEY silently breaks all email auth — is an operational misconfiguration risk that warrants a quick guard before merge, hence 4 rather than 5.
  • apps/sim/lib/auth/auth.ts — add a startup assertion that both Turnstile keys are either both set or both absent.

Important Files Changed

Filename Overview
apps/sim/app/(auth)/login/login-form.tsx Adds invisible Turnstile integration using reset→execute→getResponsePromise(15_000) pattern; also replaces the keydown useEffect for forgot-password with a native wrapper — a cleaner approach. Token is conditionally sent via x-captcha-response header.
apps/sim/app/(auth)/signup/signup-form.tsx Mirrors login-form changes: adds Turnstile ref, siteKey memo, reset→execute→getResponsePromise pattern, and conditional x-captcha-response header. Consistent with login form implementation.
apps/sim/lib/auth/auth.ts Adds emailHarmony() and captcha() plugins conditionally. Removes isDisposableEmailFull/isDisposableMxBackend calls in favour of the harmony plugin. One operational concern: TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY must be set together but no cross-validation is enforced.
apps/sim/lib/messaging/email/validation.ts Simplified to a lean client-side quick-check with a 24-domain inline set. Removes the full disposable-domains package, MX check, and validateEmail async function — all now delegated to better-auth-harmony server-side.
packages/db/schema.ts Adds nullable normalizedEmail column with UNIQUE constraint. Safe for PostgreSQL: existing NULL rows do not violate UNIQUE (each NULL is distinct). Only populated by harmony on new signups.
packages/db/migrations/0178_clumsy_living_mummy.sql Minimal migration adding nullable normalized_email text column and unique constraint. Correct and non-destructive.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant F as Login/Signup Form
    participant T as Turnstile Widget
    participant CF as Cloudflare
    participant S as better-auth Server
    participant H as emailHarmony Plugin
    participant C as captcha Plugin

    U->>F: Submit form
    alt NEXT_PUBLIC_TURNSTILE_SITE_KEY set AND ref mounted
        F->>T: reset()
        F->>T: execute()
        T->>CF: Solve invisible challenge
        CF-->>T: Challenge result
        T-->>F: getResponsePromise(15_000) resolves with token
        F->>S: POST /sign-in/email or /sign-up/email<br/>x-captcha-response: token
    else Site key not configured
        F->>S: POST /sign-in/email or /sign-up/email<br/>(no captcha header)
    end

    alt TURNSTILE_SECRET_KEY set
        S->>C: Validate captcha token
        C->>CF: Verify token via Turnstile API
        CF-->>C: Valid / Invalid
        C-->>S: Pass / Reject 400
    end

    alt SIGNUP_EMAIL_VALIDATION_ENABLED=true (sign-up only)
        S->>H: emailHarmony() check
        H-->>S: Block disposable / normalize email
    end

    S-->>F: Success or Error response
    F-->>U: Redirect or error message
Loading

Comments Outside Diff (1)

  1. apps/sim/lib/auth/auth.ts, line 322-330 (link)

    Mismatched Turnstile keys silently break all email auth

    TURNSTILE_SECRET_KEY (server) and NEXT_PUBLIC_TURNSTILE_SITE_KEY (client) are independently optional, but they only work correctly when both are set or both are absent. If an operator sets only TURNSTILE_SECRET_KEY (without the public key), the captcha() plugin enforces the x-captcha-response header on every sign-in/sign-up request, but the client never renders a widget and never sends the header — so all email logins and signups fail with a confusing server error, and no visible captcha widget is shown.

    Consider adding a startup guard that warns (or throws) when exactly one of the two keys is present:

    if (
      Boolean(env.TURNSTILE_SECRET_KEY) !== Boolean(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY)
    ) {
      throw new Error(
        'Turnstile misconfiguration: TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY must both be set or both be absent.'
      )
    }

    This would surface the misconfiguration at boot time rather than after users start hitting auth failures.

Last reviewed commit: "refactor(auth): use ..."

Server-side disposable email blocking is now handled by
better-auth-harmony. The async validateEmail (with MX check) had no
remaining callers. Only quickValidateEmail remains for client-side
form feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents form from hanging indefinitely if Turnstile never fires
onSuccess/onError (e.g. script fails to load, network drop).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
waleedlatif1 and others added 2 commits March 21, 2026 10:34
Adds TURNSTILE_SECRET_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEY, and
SIGNUP_EMAIL_VALIDATION_ENABLED to the helm chart so self-hosted
deployments can configure captcha and disposable email blocking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
onExpire now rejects the pending promise so the form doesn't hang
if the Turnstile token expires mid-challenge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The forgot-password modal used a global window keydown listener in a
useEffect to handle Enter key — a "you might not need an effect"
anti-pattern with a stale closure risk. Replaced with a native
<form onSubmit> wrapper which handles Enter natively, eliminating
the useEffect, the global listener, and the stale closure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Use .finally(() => clearTimeout(timeoutId)) to clean up the 15s
timeout timer when the captcha resolves before the deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Collaborator Author

@greptile

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Replace the manual Promise + refs + timeout pattern with the
documented getResponsePromise(timeout) API from @marsidev/react-turnstile.
This eliminates captchaToken state, captchaResolveRef, captchaRejectRef,
and all callback wiring on the Turnstile component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Captcha failures were misleadingly displayed under the password field.
Added a dedicated formError state that renders above the submit button,
making it clear the issue is with verification, not the password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@waleedlatif1 waleedlatif1 merged commit 4a34ac3 into staging Mar 21, 2026
6 checks passed
@waleedlatif1 waleedlatif1 deleted the feat/turnstile branch March 21, 2026 18:23
@waleedlatif1 waleedlatif1 restored the feat/turnstile branch March 21, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant