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.
How sign-in works
Section titled “How sign-in works”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.
Password sign-in
Section titled “Password sign-in”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:
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.
Local development sign-in
Section titled “Local development sign-in”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.
Configure Turnstile
Section titled “Configure Turnstile”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:
TURNSTILE_SITE_KEY=1x00000000000000000000AATURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AAIf 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.
Workspace creation CAPTCHA
Section titled “Workspace creation CAPTCHA”/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.
Configure Google
Section titled “Configure Google”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.
Configure production
Section titled “Configure production”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:
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.