Skip to content

Auth System

Alchemify uses custom auth — no external auth provider. Everything lives in the codebase and the database.

The first owner is created via direct SQL or sys.invite_user by a DBA. After that, owners/admins invite all other users. All logins use magic links — no password-based login in production.

  1. Owner invites users → POST /admin/invite → magic link email sent
  2. User clicks link → verify_magic_link consumes token → JWT issued
  3. Subsequent logins: user enters email → POST /auth/magic-link → magic link email → click → JWT issued
{
"sub": "user-uuid",
"role": "staff",
"email": "user@example.com",
"tenant": "acme",
"iat": 1700000000,
"exp": 1700086400
}

The server uses these claims to set PostgreSQL session variables:

  • roleSET LOCAL ROLE
  • subSET LOCAL app.user_id

The email claim is used by the frontend to display the user’s email without an extra API call on refresh. The tenant claim (present only in multi-tenant mode) records which tenant the JWT was issued for — on every authenticated request, the server checks that it matches the X-Tenant header, returning 403 on mismatch. This is defense-in-depth: the reverse proxy routes by subdomain, the header identifies the tenant, and the JWT proves the user belongs to that tenant.

Requires JWT with owner or admin role.

{ "email": "user@example.com", "role": "staff" }

Creates a new user with the specified role and sends an invite email with a magic link. Only owner can invite users with owner or admin roles — admins can only invite staff and member. Returns { ok: true } on success, 409 on duplicate email, 400 on invalid role, 403 if not owner/admin or if admin tries to invite owner/admin.

Available only when NODE_ENV !== 'production'. Password-based login for development and testing convenience.

{ "email": "user@example.com", "password": "secret", "turnstile_token": "cf-token" }

Returns { token, user: { id, email, display_name, role, needs_password } } on success, 401 on failure, 403 on Turnstile failure. Note: needs_password appears in the response body only (not in the JWT).

{ "email": "user@example.com", "turnstile_token": "cf-token" }

Creates a magic link token and sends it via email (Mailgun). In dev without Mailgun configured, the login URL is logged to the server console. Always returns { sent: true } — never reveals whether the email exists.

Protection: This endpoint is rate-limited and requires Cloudflare Turnstile verification to prevent automated abuse:

  1. Email rate limit — 3 requests per email per 15 minutes (plus-address and Gmail dot variants are normalized to the same bucket)
  2. IP rate limit — 10 requests per IP per 15 minutes (uses CF-Connecting-IP header, falls back to req.ip)
  3. Turnstile verification — the turnstile_token field is verified against Cloudflare’s siteverify API

Rate-limited requests return 429 with a Retry-After header. Failed Turnstile verification returns 403. Rate limits are checked before Turnstile to avoid unnecessary API calls.

{ "token": "one-time-token" }

Returns { token, user: { id, email, display_name, role, needs_password } } on success, 401 on invalid or reused token. Note: needs_password appears in the response body only (not in the JWT).

Requires a valid JWT. Sets the user’s password (only if password_hash is currently NULL).

{ "password": "at-least-8-chars" }

Returns { ok: true, token } with a fresh JWT on success, 409 if password is already set, 400 if password is too short (< 8) or too long (> 128).

The unauthenticated form-submission endpoint (/auth/magic-link) requires a Cloudflare Turnstile token in the turnstile_token body field. The dev-only /auth/login also requires Turnstile when TURNSTILE_SECRET is set. Configure in ~/.config/alchemify/backend-userapps.env:

TURNSTILE_SECRET=0x...

When TURNSTILE_SECRET is not set (dev), verification is skipped and all requests are allowed through. In production, the server verifies each token against Cloudflare’s API with a 5-second timeout, failing closed on errors. Verified tokens are tracked in memory to prevent replay.

The frontend includes the Turnstile widget on the login and magic-link forms via a useTurnstile React hook (apps/web/src/lib/useTurnstile.ts). The hook loads the Turnstile script once, renders an invisible widget into a ref element, and returns { token, reset }. The resulting token is sent as turnstile_token in the request body. The production site key is hardcoded in useTurnstile.ts. In dev mode it falls back to Cloudflare’s always-pass test key (1x00000000000000000000AA). The CSP is configured to allow https://challenges.cloudflare.com in script-src and frame-src.

  1. Create a Turnstile widget in the Cloudflare dashboard under Turnstile → Add site. Choose “Managed” mode. Add your production domain(s).
  2. Update the site key in apps/web/src/lib/useTurnstile.ts (the SITE_KEY constant).
  3. Set the backend secret — add TURNSTILE_SECRET to ~/.config/alchemify/backend-userapps.env. When set, the server verifies every token against Cloudflare’s API and fails closed on errors.
  4. Verify end-to-end — after deploying, test the magic-link and invite flows. The Turnstile widget should run invisibly in “Managed” mode and the server should accept the resulting tokens.

For preview/staging deploys, you can use Cloudflare’s always-pass test key (1x00000000000000000000AA) as the site key and leave TURNSTILE_SECRET unset on the server.

Magic link emails are sent via Mailgun. Configure in ~/.config/alchemify/backend-userapps.env:

MAILGUN_API_KEY=key-...
MAILGUN_DOMAIN=mg.example.com
MAIL_FROM=noreply@example.com
APP_URL=https://myapp.example.com

When Mailgun is not configured:

  • Dev: the login URL is logged to the server console
  • Production: a warning is logged (no token exposed)

Auth logic lives in PostgreSQL as SECURITY DEFINER functions. These bypass RLS so they can read/write the users table regardless of the caller’s role.

FunctionPurpose
login(email, password)Verifies credentials (dev-only endpoint)
create_magic_link(email)Generates a one-time token (15 min expiry)
verify_magic_link(token)Validates and consumes a token, returns user info + needs_password
invite_user(email, role)Creates user without password + magic link (owner/admin only; only owner can invite owner/admin roles)
set_initial_password(password)Sets password for current user (only when password_hash IS NULL)

The server calls these inside a transaction. Auth functions run as anon; invite_user runs as the caller’s role; set_initial_password runs as the user’s role and derives user_id from current_setting('app.user_id').

Deactivated users (is_active = false) are rejected by login, create_magic_link, and verify_magic_link. Existing JWTs remain valid until they expire.