Auth System
Alchemify uses custom auth — no external auth provider. Everything lives in the codebase and the database.
Auth flow
Section titled “Auth flow”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.
- Owner invites users →
POST /admin/invite→ magic link email sent - User clicks link →
verify_magic_linkconsumes token → JWT issued - Subsequent logins: user enters email →
POST /auth/magic-link→ magic link email → click → JWT issued
JWT structure
Section titled “JWT structure”{ "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:
role→SET LOCAL ROLEsub→SET 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.
Endpoints
Section titled “Endpoints”POST /admin/invite
Section titled “POST /admin/invite”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.
POST /auth/login (dev only)
Section titled “POST /auth/login (dev only)”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).
POST /auth/magic-link
Section titled “POST /auth/magic-link”{ "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:
- Email rate limit — 3 requests per email per 15 minutes (plus-address and Gmail dot variants are normalized to the same bucket)
- IP rate limit — 10 requests per IP per 15 minutes (uses
CF-Connecting-IPheader, falls back toreq.ip) - Turnstile verification — the
turnstile_tokenfield 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.
POST /auth/magic-link/verify
Section titled “POST /auth/magic-link/verify”{ "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).
POST /auth/set-password
Section titled “POST /auth/set-password”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).
Turnstile (bot protection)
Section titled “Turnstile (bot protection)”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.
Production Turnstile checklist
Section titled “Production Turnstile checklist”- Create a Turnstile widget in the Cloudflare dashboard under Turnstile → Add site. Choose “Managed” mode. Add your production domain(s).
- Update the site key in
apps/web/src/lib/useTurnstile.ts(theSITE_KEYconstant). - Set the backend secret — add
TURNSTILE_SECRETto~/.config/alchemify/backend-userapps.env. When set, the server verifies every token against Cloudflare’s API and fails closed on errors. - 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.
Email delivery
Section titled “Email delivery”Magic link emails are sent via Mailgun. Configure in ~/.config/alchemify/backend-userapps.env:
MAILGUN_API_KEY=key-...MAILGUN_DOMAIN=mg.example.comMAIL_FROM=noreply@example.comAPP_URL=https://myapp.example.comWhen Mailgun is not configured:
- Dev: the login URL is logged to the server console
- Production: a warning is logged (no token exposed)
SQL functions
Section titled “SQL functions”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.
| Function | Purpose |
|---|---|
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.