Skip to content

Accounts and sign-up

How people get into a PAL 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.

Public sign-up on a self-hosted deployment means anyone can create tenants, each with its own budget and microVM quota. Production therefore starts closed: you open it on purpose, behind a captcha, rate limits and a verified email.

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.
  1. POST /auth/signup

    Rate limit, then mode, then captcha. Creates a tenant and an owner user with email_verified_at = NULL, and mails a verification link.

  2. Prod: 202 verificationRequired

    No JWT is issued. Dev: 201 with a JWT straight away (verification is not enforced), and the mail goes to the server log.

  3. Open the emailed link

    The dashboard page /auth/verify?token=... posts the token to POST /auth/verify. Single use, 24 hours.

  4. POST /auth/login

    Returns the JWT. In prod, an unverified user gets 403 with code email_unverified, after the password has matched.

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.

Terminal window
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}
  • Enforced in prod only. Dev keeps one-step sign-up so make dev works 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/login returns 403 {"error":"email not verified","code":"email_unverified"}.
  • Existing users and OAuth sign-ins are treated as verified (migration 0023_account_lifecycle defaults users.email_verified_at to now(); only the password sign-up path writes NULL).
  • Lost the link? POST /auth/verify/resend mails a fresh one to an unverified password user.
  • A completed password reset also verifies the email.

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.

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:

Terminal window
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.

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.

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

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.

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 required otherwise.
  • 403 may only purge your own tenant if 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.

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.

The control plane refuses to start when:

  • LANTERN_SIGNUP_MODE is not open, invite or closed.
  • invite mode is set without LANTERN_SIGNUP_INVITE_SECRET.
  • LANTERN_MAILER is unknown, or smtp/http lacks its address/URL.
  • In prod, open mode has no LANTERN_TURNSTILE_SECRET.
  • In prod, mode is open or invite with LANTERN_MAILER=log: new users could never receive their verification link.

All are JSON. The /auth/* routes need no Bearer token.

{ "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

{"email","password"} returns the JWT and user as above. 401 invalid credentials, 429, and in prod 403 {"error":"email not verified","code":"email_unverified"}.

{"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.

{"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.

Same request, responses and limits as resend; sends a reset link instead.

{"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}

Section titled “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 blocks when LANTERN_AUTHZ_ENFORCE=1 (on in production: a key without the scope gets 403); either way API keys are not owners, so they get 403.

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, see Budgets) 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.

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.

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