# 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.

Source: https://tulipfarm.ai/docs/self-hosting/sign-in/

## 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](#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`](https://github.com/TulipFarm/tulipfarm/blob/main/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](https://tulipfarm.ai/docs/self-hosting/email/index.md).

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

Email links need an [email transport](https://tulipfarm.ai/docs/self-hosting/email/index.md#choose-a-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:

```sh
docker compose exec api node --env-file=/run/tulipfarm/api.env dist/auth-reset-password.js you@company.com
```

From a source checkout with `DATABASE_URL` set, the same command is
`pnpm auth:reset-password you@company.com`. 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

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 `dev@tulipfarm.test`. Set an [email provider](https://tulipfarm.ai/docs/self-hosting/email/index.md#configuration) 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

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](https://developers.cloudflare.com/turnstile/) 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:

```sh
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](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)
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

`/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

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](https://tulipfarm.ai/docs/self-hosting/secrets/index.md).

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

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:

```sh
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.
