Skip to content
TulipFarmDocs

Last updated

Configure sign-in

Set up email links, Google sign-in, optional passwords, and Cloudflare Turnstile bot protection for self-hosted TulipFarm, and keep the auth secret stable.

The API uses Better Auth 1.7.6 with PostgreSQL tables in the app schema. People sign in with email links and Google; email and password sign-in is off unless you turn on password sign-in. Magic links expire after 15 minutes and are stored as hashes. Google sign-in automatically links accounts with the same verified email address.

Gmail and Googlemail aliases share one account. The canonical email helper (packages/workspaces/src/canonical-email.ts) removes +tags and dots and maps both domains to gmail.com. Other domains are only trimmed and lowercased. A new sign-in or Google sign-up for another user’s alias returns ACCOUNT_EXISTS and sends no email. Invitations, invitation redemption, and the per-recipient auth limit use the same canonical address.

Auth requests also use the shared API abuse limits.

Email requests queue a delivery. A successful request means the API accepted the email, not that the email reached the inbox. Check the workflow in Temporal UI when delivery fails. See Send email.

Accounts without a name must complete /complete-profile before opening workspace setup or workspace pages. Names supplied during registration or by Google skip this step. Profile updates trim names and require 1 to 80 characters. Login, registration, profile completion, and failed sign-in retries preserve the requested workspace URL. Authentication routes and unsafe external destinations fall back to / to prevent redirect loops and off-site navigation.

Email links need an email transport. An instance without one can add email and password sign-in: set AUTH_EMAIL_PASSWORD=true for the API and restart it (docker compose up -d api). It is off by default; any value other than true or false stops the API at startup. The login and sign-up pages then ask for a password next to the email link and Google buttons.

  • Anyone who can reach the instance can create an account, as with email links. Workspaces still join by invitation. Without email, invitations show a link to copy and send yourself.
  • Passwords are 8 to 128 characters. Each client gets 10 password attempts a minute and each address 10 every 15 minutes, and Turnstile applies when it is configured.
  • People change their password under Account. Changing or resetting a password signs out their other sessions.
  • People who signed up with an email link or Google have no password. They set one with a reset link. An email link sign-in proves the address, so it removes a password set by someone whose address was never confirmed.
Transport configured Forgot password?
SMTP or ACS Emails a one-hour, one-use reset link, sent by the worker like email links.
None Tells the person to ask an administrator, who prints a reset link and sends it to them.

To print a one-hour reset link as an administrator with Docker Compose:

Terminal window
docker compose exec api node --env-file=/run/tulipfarm/api.env dist/auth-reset-password.js [email protected]

From a source checkout with DATABASE_URL set, the same command is pnpm auth:reset-password [email protected]. It fails when no account has that address. The link opens /login/reset-password on WEB_ORIGIN.

Leave AUTH_EMAIL_PASSWORD unset to keep email links and Google only.

Without an email provider (EMAIL_PROVIDER, ACS_CONNECTION_STRING, or SMTP_HOST), a non-production API bound to a loopback host sends no email. It logs the magic link and serves it once from GET /v1/dev/sign-in-link?email=. After you request a link, the login page shows Open dev sign-in link. Any email address works, for example [email protected]. Set an email provider to test real delivery. Under the same conditions, GET /v1/dev/session?email=&redirect= signs in directly and redirects to the web app.

Use localhost consistently in the browser; 127.0.0.1 is a different cookie host and is not the configured web origin. The API defaults to http://localhost:3001, and email links open http://localhost:5173/verify-email.

Login and registration require a fresh Turnstile challenge before requesting an email link. The API verifies the token with Cloudflare before storing a magic-link token or queuing email. With real keys, verification requires success: true, action sign_in, and the exact hostname from WEB_ORIGIN. Requests have a five-second deadline and no automatic retry. Cloudflare enforces five-minute expiry and single use.

Create a Turnstile widget that allows your public hostname. Keep pre-clearance disabled and hostname validation enabled.

Both values enter the API as TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. The browser fetches only the public key from GET /v1/auth/options. No frontend build credential is needed. When NODE_ENV is unset, development, or test, startup without either key uses Cloudflare’s public always-pass dummy pair:

Terminal window
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

If your shell already exports real keys, unset both and restart the API to use the defaults. Explicit paired overrides still work. Supplying only one key prevents startup in every environment. Production requires both real keys and rejects all documented dummy sitekeys and secret keys. Other NODE_ENV values never default to dummy keys and also reject explicit dummy keys.

The dummy widget still loads from Cloudflare, and the API still calls Siteverify. For a local dummy pair, the API requires the exact dummy token, success: true, and Cloudflare’s metadata.result_with_testing_key: true marker. The live dummy response uses example.com without an action, so hostname and action binding remain requirements for real keys only. Internet access is still required. The official testing guide lists failure and replay keys for explicit local overrides. Production secrets reject dummy tokens.

The form disables email submission until verification succeeds. Expired or failed challenges offer Retry verification. An email request consumes the token, so every retry requires a new challenge. Form values survive failed submissions. If the Cloudflare script fails to load, Reload page retries loading it. Google OAuth and consumption of already-issued email links do not require a challenge.

If a Content Security Policy is configured in front of TulipFarm, allow https://challenges.cloudflare.com in script-src and frame-src.

/create-workspace uses the same public Turnstile configuration and shared widget as sign-in, with action create_workspace. This challenge also applies to users who signed in with Google. Creation stays disabled until verification succeeds. Failed configuration loads, expired challenges, and rejected submissions offer retry actions and preserve the workspace name and URL.

POST /v1/workspaces accepts challengeToken in its JSON body. Before inserting a new workspace, the API requires successful Siteverify validation for the configured hostname and create_workspace action. Missing, invalid, expired, and replayed tokens fail closed. The existing local dummy-key rules still apply.

The store locks each user/request-ID pair in PostgreSQL before checking for an existing creation. An exact retry returns that workspace without verifying the consumed token again. Changed input returns a conflict; another user or request ID requires a new challenge. The creation and storage reservation commit together. If storage fails after Siteverify consumes a token, retry the same request ID with a fresh challenge. All attempts, including retries, retain authentication, origin checks, and the existing three-attempts-per-hour user limit.

Create or update a Web application OAuth client in Google Cloud Console. Add your public origin, such as https://tulipfarm.example.com, as an authorized JavaScript origin. Add this authorized redirect URI:

  • https://tulipfarm.example.com/v1/auth/callback/google

With the Docker Compose stack, the API shares the public origin, so the redirect URI is the public URL followed by /v1/auth/callback/google.

If the consent screen is in testing mode, add the accounts used for verification to its test users. Supply the client ID and secret to the API as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from your secret store.

Google is enabled only when both credentials exist in the API environment. Setting only one prevents startup. The UI reports an unavailable provider and offers email sign-in when Google has not been configured.

The Docker Compose setup job generates a stable BETTER_AUTH_SECRET. If you run the services another way, create a stable signing secret before the first start:

Terminal window
node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64"))'

Do not regenerate this secret on each deploy. Production requires at least 32 characters and has no development fallback.

Set BETTER_AUTH_URL to your public API origin and WEB_ORIGIN to your public web app origin. Set VITE_API_URL in the web build only when targeting another API origin. CORS allows credentials from the configured web origin. Authentication endpoints live under /v1/auth and are excluded from the health OpenAPI document and request URL logs.

Workspace routes require a session and a workspace membership.

Was this page helpful?