Accounts & sign-up
How people get into a Lantern deployment and how they leave: gated sign-up, email verification, password reset, data export and workspace deletion. Every control here is on the control plane (:8080); the dashboard pages are thin clients over the endpoints below.
Sign-up modes
LANTERN_SIGNUP_MODE controls POST /auth/signup. Unset, it is closed when LANTERN_ENV is prod, production or staging, and open otherwise. Any other value stops the server at startup.
| Mode | Behaviour |
|---|---|
closed | Every sign-up gets 403 {"error":"sign-up is closed"}. Default in prod. |
invite | Needs an inviteCode bound to the email, else 403. Requires LANTERN_SIGNUP_INVITE_SECRET. |
open | Anyone, subject to the rate limit and captcha. Default in dev. |
Sign-up, verify, login
Try it
Against a dev stack (make run-api), sign-up is open and returns a token immediately. The verification mail is printed to the control-plane log by the log mailer.
curl -s -X POST http://localhost:8080/auth/signup \
-H 'Content-Type: application/json' \
-d '{"email":"new.user@example.com","password":"correct-horse-battery","name":"New User"}'
# 201 {"token":"...","user":{"id":"...","tenantId":"...","email":"new.user@example.com","name":"New User","role":"owner"}}
# grab the link from the log, then redeem the token from its ?token= value
curl -s -X POST http://localhost:8080/auth/verify \
-H 'Content-Type: application/json' -d '{"token":"<token from the link>"}'
# 200 {"verified":true}Email verification
- Enforced in prod only. Dev keeps one-step sign-up so
make devworks unchanged, but still sends the mail so the flow is testable. - Token: 32 random bytes, emailed as a link, valid 24 hours, usable once. Only its SHA-256 is stored.
- Blocked until verified (prod): sign-up returns no JWT, and
POST /auth/loginreturns403 {"error":"email not verified","code":"email_unverified"}. - Existing users and OAuth sign-ins are treated as verified (migration
0023_account_lifecycledefaultsusers.email_verified_attonow(); only the password sign-up path writesNULL). - Lost the link?
POST /auth/verify/resendmails a fresh one to an unverified password user. - A completed password reset also verifies the email.
Password reset
POST /auth/forgot mails a link to /auth/reset?token=... on the dashboard. The token is valid 1 hour and works once. A successful reset sets the new password, verifies the email, and burns every other outstanding reset link for that user. Passwords must be at least 12 characters. Reset applies to password (local) accounts only.
No account-existence leak. /auth/forgot and /auth/verify/resend return the same 202 body whether or not the address has an account, and the mail itself is sent in the background, so relay latency does not show in response time (an existing account still costs one extra database write before the reply). In prod, signing up with an already-registered email returns the same 202 verificationRequired as a new one. (In dev it returns 409 email already registered.)
Links point at OAUTH_DASHBOARD_URL (default http://localhost:3001), so set it in any deployed environment or emailed links will point at localhost.
Invite codes
Stateless and email-bound: <expiryUnix>.<hex HMAC-SHA256(secret, lower(email) + "|" + expiryUnix)>. There is no table and no admin API. A code works only for the email it was minted for and only until its expiry; it is effectively single-use because emails are unique. Mint one on any machine that has the secret:
EXP=$(( $(date +%s) + 7*86400 )); EMAIL=new.user@example.com
echo "$EXP.$(printf '%s|%s' "$EMAIL" "$EXP" | openssl dgst -sha256 -hmac "$LANTERN_SIGNUP_INVITE_SECRET" -r | cut -d' ' -f1)"The invitee sends it as inviteCode in the sign-up body. A missing, expired, malformed or wrong-email code gets 403 a valid invite code is required to sign up.
Captcha
Cloudflare Turnstile, verified server-side against siteverify with the caller IP. Required when LANTERN_TURNSTILE_SECRET is set, and always required for open sign-up in prod. Closed deployments never call Turnstile (the mode check runs first). The client sends the widget token as captchaToken.
- No token:
400 captcha is required. - Token rejected:
403 captcha verification failed. - Turnstile unreachable:
503 captcha verification unavailable(sign-up fails closed). - Required but no secret configured:
503 sign-up is not available. Startup refuses this combination in prod, so you should not see it.
The dashboard renders the widget on the sign-up form when NEXT_PUBLIC_TURNSTILE_SITE_KEY is set at build time.
Rate limits
Fixed-window counters in Redis, keyed by client IP (and email where noted). The IP is the TCP peer; X-Forwarded-For is honored only when the peer is inside LANTERN_TRUSTED_PROXIES. If Redis is absent or errors, the limiter fails open so an outage cannot lock owners out. Exceeding a limit returns 429.
| Endpoint | Limit | Key |
|---|---|---|
POST /auth/signup | 10 / hour | IP |
POST /auth/login | 10 / minute | IP, and separately email |
POST /auth/forgot, POST /auth/verify/resend | 20 / hour, and 5 / hour | IP, and email (each endpoint has its own counters) |
POST /auth/verify, POST /auth/reset | 20 / hour | IP (per endpoint) |
Body: {"error":"too many requests; please try again later"} (login says too many login attempts; please try again later).
Data export
GET /v1/tenants/{id}/export returns everything the workspace owns as one JSON document, sent with Content-Disposition: attachment; filename="lantern-export-<tenantId>.json". Owner JWT only; the id must be your own tenant. Another tenant's id is 404 (existence is not leaked), a non-owner is 403 owner role required, no token is 401.
{
"tenantId": "…",
"exportedAt": "2026-10-27T12:00:00Z",
"tables": {
"agents": [ … ],
"runs": [ … ],
"tenants": [ { … } ]
}
}tables has one array per table that has a tenant_id column (discovered from the database catalog, so new tables are included automatically), plus the tenant row. Credential columns are left out by name: any column with a secret, token, key, password, hash, enc/encrypted, ciphertext or credential(s) segment (for example password_hash, agent_triggers.secret_enc, data_planes.reg_token_hash), plus provider_config and connector_installs.config. Dedup keys (idempotency_key, idem_key, replay_key) and key_prefix are exported. The rule is structural, so a new credential column is excluded by default. The document is built in memory, so very large workspaces produce a large response.
Workspace deletion
DELETE /v1/tenants/{id} permanently erases the workspace (GDPR right to erasure). In one transaction it deletes every tenant-scoped row (runs and their journal events first, then agents, every other tenant table, users) and finally the tenant itself. It cannot be undone.
- Owner JWT required;
403 owner role requiredotherwise. 403 may only purge your own tenantif the id is not yours.- Any failure rolls the whole transaction back and returns
500 internal error; nothing is removed. - Success:
200 {"tenant_id":"…","deleted":{"runs":12,"agents":3,…}}, a per-table row count.
The API has no confirmation parameter; the safeguard is in the dashboard. Settings > Your data (owners only) offers Export and Delete, and Delete requires typing delete my workspace before the button enables.
Mailer
Verification and reset mail go through one of three drivers, chosen by LANTERN_MAILER (default log). Mail is plain text and sent in the background with a 30 second limit; a failed send is logged, not returned to the caller.
| Driver | Behaviour |
|---|---|
log | Delivers nothing. Dev logs recipient, subject and body (the link). Prod logs recipient and subject only, so tokens never reach prod logs. |
smtp | Standard-library SMTP to LANTERN_SMTP_ADDR, upgraded with STARTTLS when offered. Credentials are refused over an unencrypted connection to a non-localhost server. |
http | POSTs JSON {from,to,subject,text} to LANTERN_MAIL_HTTP_URL with Authorization: Bearer LANTERN_MAIL_HTTP_TOKEN (the shape of Resend's /emails API). 15 second timeout; a non-2xx response is an error. |
Startup checks
The control plane refuses to start when:
LANTERN_SIGNUP_MODEis notopen,inviteorclosed.invitemode is set withoutLANTERN_SIGNUP_INVITE_SECRET.LANTERN_MAILERis unknown, orsmtp/httplacks its address/URL.- In prod,
openmode has noLANTERN_TURNSTILE_SECRET. - In prod, mode is
openorinvitewithLANTERN_MAILER=log: new users could never receive their verification link.
Endpoint reference
All are JSON. The /auth/* routes need no Bearer token.
POST /auth/signup
{ "email": "…", "password": "…(12+ chars)", "name": "…", "inviteCode": "…", "captchaToken": "…" }| Status | Body |
|---|---|
| 201 | Dev only. {token, user:{id, tenantId, email, name, role}}. name defaults to the part of the email before @. |
| 202 | Prod. {"verificationRequired":true,"message":"Check your email for a link to verify your account."}. No JWT. |
| 400 | invalid request body; email and password are required; a valid email is required; password must be at least 12 characters; captcha is required |
| 403 | sign-up is closed; a valid invite code is required to sign up; captcha verification failed |
| 409 | Dev only. email already registered |
| 429 | Rate limited. |
| 503 | captcha verification unavailable; sign-up is not available |
POST /auth/login
{"email","password"} returns the JWT and user as above. 401 invalid credentials, 429, and in prod 403 {"error":"email not verified","code":"email_unverified"}.
POST /auth/verify
{"token":"…"} returns 200 {"verified":true}. 400 invalid or expired token for an unknown, reused, expired or wrong-purpose token. 429 past 20 per hour per IP.
POST /auth/verify/resend
{"email":"…"} returns 202 {"status":"ok","message":"If that address has an account, we've sent it a link."} for any well-formed email. 400 a valid email is required, 429.
POST /auth/forgot
Same request, responses and limits as resend; sends a reset link instead.
POST /auth/reset
{"token":"…","password":"…"} returns 200 {"reset":true}. 400: password must be at least 12 characters or invalid or expired token. 429. The token is checked after the password length, so a short password does not burn it.
GET /v1/tenants/{id}/export and DELETE /v1/tenants/{id}
Bearer owner JWT. See Data export and Workspace deletion. Both routes also pass the admin scope annotation, which only blocks when LANTERN_AUTHZ_ENFORCE=1; either way API keys are not owners, so they get 403.
Free-tier caps
Every new workspace, whether created by password sign-up or the first Google login, starts with a hard-fail daily spend cap and a sandbox quota so a new account cannot run up cost. The cap is the tenant-wide budget (the same row PUT /v1/budgets/tenant edits) and the quota is the same one PUT /v1/runtime/quota edits, so an owner can raise or remove either. Existing workspaces and the seeded dev workspace are never touched. Set a variable to off to skip that cap.
| Variable | Default | Purpose |
|---|---|---|
LANTERN_FREE_TIER_COST_USD_PER_DAY | 2.00 | Tenant-wide LLM spend per day (hard fail, HTTP 402). |
LANTERN_FREE_TIER_MAX_VMS | 2 | Concurrent sandbox VMs. Compute hours (4/day) and egress (1 GB/day) are fixed. |
LANTERN_FREE_TIER_RUNTIME_COST_USD_PER_DAY | 1.00 | Sandbox spend per day. |
Running agents before you verify
Where verification is required (production), POST /v1/runs from an unverified user returns 403 {"code":"email_unverified"}. Login and sign-up already withhold tokens from unverified users, so this is a second check. Google sign-in counts as verified, and API keys are not affected. In dev the gate is off.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
LANTERN_ENV | unset (dev) | prod/production/staging turns on closed-by-default sign-up, email verification and the startup checks. |
LANTERN_SIGNUP_MODE | closed in prod, open in dev | open, invite or closed. |
LANTERN_SIGNUP_INVITE_SECRET | none | HMAC key for invite codes. Required for invite mode. |
LANTERN_TURNSTILE_SECRET | none | Turnstile secret. Setting it requires a captcha on every sign-up; required for open in prod. |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | none | Dashboard build-time site key; renders the widget. |
LANTERN_MAILER | log | log, smtp or http. |
LANTERN_MAIL_FROM | Lantern <no-reply@localhost> | From address for smtp and http. |
LANTERN_SMTP_ADDR | none | host:port. Required for smtp. |
LANTERN_SMTP_USER, LANTERN_SMTP_PASSWORD | none | SMTP credentials (PLAIN auth). |
LANTERN_MAIL_HTTP_URL | none | Endpoint for the http driver. Required for it. |
LANTERN_MAIL_HTTP_TOKEN | none | Bearer token for the http driver. |
OAUTH_DASHBOARD_URL | http://localhost:3001 | Base URL used in emailed verify/reset links. |
LANTERN_TRUSTED_PROXIES | empty | CIDRs whose X-Forwarded-For is trusted for rate-limit keys. |
Limits
- Invite codes cannot be listed or revoked; to cancel one, rotate
LANTERN_SIGNUP_INVITE_SECRET(which invalidates all of them). - Rate limiting needs Redis; without it only the captcha and sign-up mode protect
/auth/*.
See also Security and the API reference.