Lucy AI/Docs
GitHubOpen app

Getting started

  • Introduction
  • Quick start
  • Desktop app

Using Lucy

  • Chat & models
  • Memory
  • Personas
  • Connectors
  • Workflows
  • Voice
  • Security & 2FA
  • Themes & account

Developers

  • Architecture
  • Self-hosting
  • Embedding Lucy
  • HTTP API
  • CLI
  • MCP server
  • Contributing

Security & 2FA

Everything on this page applies to connected mode — Lucy with Supabase configured. Standalone mode (no Supabase env vars) has no accounts and no login: every route is public, there is no session, and the auth pages just point you back to /chat. If you need real users, sign-in, and access control, run connected mode (see Self-hosting).

Two modes at a glance

StandaloneConnected (Supabase)
LoginNone — all routes publicEmail + password, or Google
Route protectionOff/chat, /workflows, /settings, /account require a session
2FAn/aTOTP app and/or email code, gated at login
Provider keysClient-side, in the browserAES-256-GCM encrypted, server-side
Data isolationOne browser, one userPer-user row-level security

Connected mode turns on automatically when NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set.

Sign in & sign up

  • Email + password — sign up at /auth/signup with a password of at least 8 characters and an optional company name. Supabase sends a confirmation email; click the link to activate the account before the session goes live.
  • Google — "Sign in with Google" runs the Supabase OAuth flow and returns to /auth/callback, which exchanges the code for a session cookie and lands you on /chat.

The first account on a fresh deployment is auto-promoted to admin (or any address in LUCY_ADMIN_EMAIL). Roles live in Supabase app_metadata.lucy_role, which only the service role can write — users can't promote themselves. See Themes & account for the admin area.

Route protection

A middleware (proxy.ts) guards the app. In standalone mode it allows everything. In connected mode it checks the Supabase session on protected routes and redirects unauthenticated visitors to the login page, preserving where they were headed:

/chat  →  no session  →  /auth/login?redirectTo=/chat
ProtectedAlways public
/chat · /workflows · /settings · /account/auth/* · /api/* · /embed · /personas · /onboarding · /

If a session check throws, the middleware fails open rather than locking you out of the app.

Two-factor authentication

2FA is gated at a login challenge — after a correct password (or OAuth), Lucy checks for a second factor before letting you reach the app. Both kinds can be enabled together from Account → Security.

MethodHow it worksWhere it's enforced
Authenticator app (TOTP)Scan a QR at /auth/two-factor-setup, then enter a 6-digit code each sign-in (/auth/two-factor-challenge)Supabase MFA — the session must reach AAL2; a password-only session is AAL1
Email code (OTP)A 6-digit code is emailed; enter it at /auth/2faA signed, httpOnly cookie set on success, checked server-side

TOTP is built on Supabase's MFA factors. The middleware reads the Authenticator Assurance Level: if you have a verified factor but the session is still AAL1, you're redirected to the challenge. Five wrong codes signs you out and sends you to /auth/account-locked.

Email 2FA is opt-in via the two_factor_email_enabled flag on your profile. The middleware enforces it with an HMAC-signed lucy_2fa cookie (httpOnly, 12-hour TTL, secret derived from the service-role key). The cookie — not a client-side flag — is the real gate, so navigating straight to /chat after a password login won't skip the code. Codes are sent over SMTP; without SMTP configured, email 2FA can't deliver.

Password reset (emailed code)

Reset uses a 6-digit code by email, not a magic link:

  1. Request at /auth/forgot-password → POST /api/auth/reset/request.
  2. Lucy emails a code (15-minute expiry) and you land on /auth/reset-password.
  3. Enter the code + a new password → POST /api/auth/reset/confirm updates the password via the service-role admin API.

The request endpoint is rate-limited per IP and returns an identical response whether or not the email exists, so it never reveals which addresses are registered.

How verification codes are stored

Both reset and email-2FA codes share one hardened mechanism (lib/email/codes.ts, table lucy.email_verification_codes):

PropertyValue
Format6 digits
Storagescrypt hash (salt:dk) — never the plaintext code
Expiry15 minutes
Max attempts5, then the code is dead
Single useMarked consumed_at once accepted

The table is service-role only (RLS on, no client policies), so the browser can never read codes — only server route handlers touch them.

Active devices

Each sign-in registers the browser in lucy.member_devices (fingerprint from user-agent, language, screen size and timezone; browser, OS, IP, last-active time). Account → Security lists every device, flags the current one, and lets you remove any you don't recognize. Row-level security scopes reads and deletes to your own rows (auth.uid() = user_id); writes go through a service-role track route.

The account page

Reach it from your avatar at the bottom-left of the sidebar. Three sections:

  • Profile (/account/profile) — display name, avatar URL, company. Email is read-only (managed by your login provider).
  • Security (/account/security) — change password, enable/disable the authenticator app, toggle email 2FA, and manage devices.
  • Billing (/account/billing) — plan and usage. Self-hosted Lucy is free.

Encrypted provider keys

Provider API keys you save in connected mode are AES-256-GCM encrypted at rest in lucy.provider_configs. Encryption is keyed from SUPABASE_SERVICE_ROLE_KEY and rows are tagged enc:v1: (iv:tag:ciphertext). Keys are decrypted server-side only — they never reach the browser during a chat or workflow run. Older rows written with the legacy XOR obfuscation are still readable and get re-encrypted opportunistically when read. See Chat & models for how keys are used.

Per-user isolation (row-level security)

Every Lucy table has RLS enabled with policies keyed to the signed-in user, so one account can never read another's data:

create policy "Users can manage own conversations" on conversations
  for all using (auth.uid() = user_id);

The same pattern covers messages, provider configs, preferences, workflows, runs, screenings, devices, and profiles. The browser uses the anon key and is bound by these policies; only server routes that hold the service-role key can cross user boundaries — and that key is server-only, never shipped to the client.

Email delivery

Transactional email (password-reset and email-2FA codes) goes out over SMTP via nodemailer, configured with SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM_EMAIL. Port 587 uses STARTTLS; set SMTP_SECURE=ssl for 465. If SMTP isn't configured, sends are skipped gracefully rather than erroring — but password reset and email 2FA then have no way to deliver, so configure SMTP if you rely on them.

VoiceThemes & account