Appearance
Authentication & sessions
FlowRunner signs you in with email and password, stores no server-side session record, and gates every API route behind a logged-in session. This page describes exactly what the code enforces today.
How sign-in works
Sign-in uses an Auth.js v5 Credentials provider. When you submit the login form, the server:
- Looks up the user by email in the
userscollection. - Compares your password against the stored hash with
bcrypt.compare. - Issues a session only if the hash matches.
Passwords are never stored in plain text. Every hash is produced with bcrypt at cost factor 12 — both when you accept an invite and when you change your password. bcrypt is deliberately slow, so an attacker who steals the database still faces an expensive brute-force per password.
NOTE
A wrong email, a wrong password, and a rate-limited attempt all return the same "invalid credentials" result. The login form never reveals whether an email exists.
Brute-force lockout
Before any password check, each login attempt is counted against a per-email key:
ts
const rl = await rateLimitByKey(`login:${email.toLowerCase()}`, 8, 900_000);
if (!rl.ok) return null; // surfaces as "invalid credentials"That is 8 attempts per 15-minute window, per email address. The 9th attempt in a window is rejected before bcrypt runs, so guessing is throttled and the expensive hash comparison is not used as a CPU-exhaustion lever. The limiter is a fixed-window counter backed by MongoDB with a TTL index, so it works correctly across serverless instances.
WARNING
The lockout keys on email only — not yet on client IP (there is a TODO to add IP once it is available in that callback). An attacker spreading guesses across many email addresses is not slowed by this control alone.
Stateless JWT sessions
Sessions use the jwt strategy. There is no session table — your session lives entirely in a cookie holding a JSON Web Token that Auth.js signs and verifies with the server-side secret AUTH_SECRET (a required environment variable; the server refuses to boot without it). The token carries your user id and, if set, your role, which the session callback copies onto session.user for route handlers to read.
WARNING
Because sessions are stateless, there is no server-side "log out everywhere" switch. A token stays valid until it expires (the Auth.js default lifetime, as no custom maxAge is set) or until AUTH_SECRET is rotated — which invalidates all sessions at once. Treat AUTH_SECRET as a high-value secret; see /deploy/env.
Cross-subdomain SSO falls out of this design: any FlowRunner app served on a sibling subdomain that shares the same AUTH_SECRET (and cookie domain) can verify the same session token without a shared session store, because the token is self-contained and self-verifying.
The edge route guard
Requests hit an edge-runtime middleware (matcher /api/:path*) that runs the authorized callback before any handler. Everything under /api requires a logged-in session except an explicit public allowlist:
| Public namespace | Why it is public |
|---|---|
/api/auth | The login/session endpoints themselves |
/api/waitlist, /api/invite | Joining the waitlist and accepting an invite |
/api/run, /api/openapi | Per-flow auth is enforced by the flow itself |
/api/forms | Public form submit (honeypot + rate limit) |
/api/cron | Gated by CRON_SECRET |
/api/health | Leaks nothing |
This guard is defense-in-depth: every handler still runs its own auth() and ownership checks. The middleware is a backstop that keeps a route from being reachable if a handler ever forgets one — it does not replace per-route authorization.
Signup is invite-only
Self-signup is closed during early access. POST /api/auth/register always returns 403 and points you to the waitlist. Accounts are created only when you open a valid, unexpired, unused invite token: the accept endpoint sets your password (minimum 8 characters, hashed at cost 12), provisions a personal workspace, optionally adds you to an inviting workspace, and marks the token used so it cannot be replayed.