# Authentication Form-based authentication: two pages, `/signin` and `/signup`, sharing one schema and one set of RPC handlers. Credentials store as bcrypt hashes in Postgres. Each RPC validates against the hash and calls `session.login()`. The reactive session updates both pages immediately on success, no full-page reload. The Elements schema setup automatically installs the `pgcrypto` extension, so `crypt()` and `genSalt()` are available in migrations and rpc. ## Migration ```bash elements create migration 'add users' -tables=users ``` `app/migrations/-add-users.migration.sql`: ```sql -- add users -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table users ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), email text not null unique, passwordHash text not null ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); ``` The two columns added on top of the scaffold are `email` (unique, so no two users share one) and `passwordHash` (stores the bcrypt hash, never the plaintext). Email is the identifier because it is what a password manager keys on and what a reset flow needs; a `handle` column can be added beside it as a display name. Store it lowercased. Postgres `text` is case-sensitive and `Ada@x.com` and `ada@x.com` are the same mailbox, so the unique constraint has to see the same string every time; the RPC below lowercases before it writes or reads. ## Shared auth RPC Two pages call these, so they go in `app/shared/services/`. A page's `@rpc` functions normally live in its own `template.ehtml` (`elements man html/templates`); the second consumer is what moves them out. `app/shared/services/auth.ts`: ```ts import { sql, session, AuthError } from "@elements/app"; interface User { id: string; email: string; } export const MIN_PASSWORD = 8; /** * Both RPC take the raw form values, so both validate. A browser check is a * convenience the caller can skip; this is the check that counts. */ function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function isEmail(email: string): boolean { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email); } /** @rpc */ export function signin(email: string, password: string) { let address = normalizeEmail(email); if (!address || !password) { throw new AuthError("enter your email and password"); } let user = sql( `select id, email from users where email = ${address} and passwordHash = crypt(${password}, passwordHash)`, ).first(); if (!user) { throw new AuthError("invalid email or password"); } session.login({ userId: user.id, userName: user.email }); } /** @rpc */ export function signup(email: string, password: string) { let address = normalizeEmail(email); if (!isEmail(address)) { throw new AuthError("enter a valid email address"); } if (password.length < MIN_PASSWORD) { throw new AuthError(`password must be at least ${MIN_PASSWORD} characters`); } let taken = !sql(`select 1 from users where email = ${address}`).empty(); if (taken) { throw new AuthError("that email is already registered"); } let user = sql<{ id: string }>( `insert into users (email, passwordHash) values (${address}, crypt(${password}, genSalt('bf', 12))) returning id`, ).firstOrThrow(); session.login({ userId: user.id, userName: address }); } /** @rpc */ export function signout() { session.logout(); } ``` **Signin says "invalid email or password", never which one was wrong.** Telling a stranger that an address exists but the password was wrong turns the form into a way to enumerate your users. Signup has to say "already registered" to be usable, which leaks the same fact; a product that cannot afford that sends a "someone tried to register this address" email instead and shows the same "check your inbox" screen either way. **The taken check is a nicety, not the guarantee.** Two signups racing both pass `select 1` and one hits the unique index, so the constraint is what actually keeps the table clean. Catch `SqlError` around the insert and rethrow `AuthError("that email is already registered")` if you want the friendly message on that path too. The signin query reads `passwordHash = crypt(${password}, passwordHash)`. Both occurrences are the same column. `crypt()` re-hashes the submitted password using the algorithm and salt encoded in the stored hash and compares the result, all in one expression. The plaintext never leaves Postgres and TypeScript never sees the stored hash. Signup uses `genSalt('bf', 12)` to mint a fresh bcrypt salt for the new user. `bf` is bcrypt; pick a different scheme only if you understand the trade-off. **Pass the cost.** The second argument is the work factor, and it is the whole point of bcrypt: it is how long one hash takes, and therefore how long one guess takes for someone holding a stolen `users` table. pgcrypto defaults `bf` to 6, which is far too cheap on modern hardware. Measured on the bundled Postgres: ```text crypt('x', gen_salt('bf')) 4.1 ms cost 6, the default crypt('x', gen_salt('bf', 12)) 235 ms cost 12 ``` That is 57x more work per guess. Cost 12 is the right default today. Raise it as hardware gets faster; the cost is stored in the hash, so old rows keep verifying at the cost they were written with and you can re-hash on next signin. `signout` has no caller in these two pages, which redirect a signed-in visitor away. It belongs to whatever signed-in UI the app grows, a header or an account menu, calling it as `onclick={() => signout()}`. `AuthError` is a safe error: its message reaches the browser as-is and the RPC client re-throws it. ## Declaring session fields `session.login({ userId, userName })`, `session.get('userId')`, and `session.getOrThrow('userName')` only typecheck once you declare those fields on `SessionData`. Do it once per app in `app/types/session.d.ts`. The scaffold ships this file with the block commented out: ```ts declare module "@elements/app" { interface SessionData { userId: string; userName: string; } } ``` `SessionData` is a global augmentation: declare it once and every `session.login`/`get`/`getOrThrow` across the app is typed against it. Without it, `keyof SessionData` is empty, so `session.login({ userId })` reports "expected 0 arguments" and `session.get('userId')` rejects the key. ## Signin page ```bash elements create page signin ``` Update `app/pages/signin/index.ts` and `template.ehtml` with the contents below. `app/pages/signin/index.ts`: ```ts import { Request, Response, redirect, session } from "@elements/app"; import signin from "#app/pages/signin/template"; export default function route(req: Request, res: Response) { if (session.isLoggedIn()) { redirect("/"); return; } return new signin(); } ``` `app/pages/signin/template.ehtml`: ```ehtml import "./style.css"; import { redirect } from "@elements/app"; import { signin } from "#app/shared/services/auth"; interface SigninForm { email: string; password: string; error: string; } function onSubmit(form: SigninForm) { try { signin(form.email, form.password); form.error = ""; redirect("/"); } catch (err: any) { form.error = err.message; } }
onSubmit(form)}>

sign in

need an account? sign up
``` ## Signup page ```bash elements create page signup ``` Update `app/pages/signup/index.ts` and `template.ehtml`. `app/pages/signup/index.ts`: ```ts import { Request, Response, redirect, session } from "@elements/app"; import signup from "#app/pages/signup/template"; export default function route(req: Request, res: Response) { if (session.isLoggedIn()) { redirect("/"); return; } return new signup(); } ``` `app/pages/signup/template.ehtml`: ```ehtml import "./style.css"; import { redirect } from "@elements/app"; import { signup, MIN_PASSWORD } from "#app/shared/services/auth"; interface SignupForm { email: string; password: string; error: string; } function onSubmit(form: SignupForm) { try { signup(form.email, form.password); form.error = ""; redirect("/"); } catch (err: any) { form.error = err.message; } }
onSubmit(form)}>

sign up

at least {MIN_PASSWORD} characters
have an account? sign in
``` ## Input attributes the browser needs A password manager reads the form, not your intent. These attributes are what make it offer to fill on signin and offer to generate on signup, and leaving them off is the single most common way an auth page ships feeling broken. | attribute | signin | signup | why | |---|---|---|---| | `type` | `email` | `email` | phone keyboards show the `@` key, and the browser validates the shape | | `autocomplete` on email | `email` | `email` | identifies the field to the manager and to autofill | | `autocomplete` on password | `current-password` | `new-password` | `current-password` offers the saved one; **`new-password` is what makes the browser suggest a strong password and offer to save it** | | `name` and `id` | yes | yes | a manager will not reliably fill an unnamed input, and `for`/`id` ties the label to it | | `autocapitalize="none"` | yes | yes | iOS capitalizes the first letter of a text field, which corrupts the address | | `spellcheck={false}` | yes | yes | stops the red underline under every address | | `minlength` | no | `MIN_PASSWORD` | the browser blocks submit before a round trip; the RPC still checks | **`autocomplete="new-password"` is the one to get right.** It is the only signal that asks the browser to generate a password, and it is easy to write `current-password` on both pages, or to omit it and get neither behavior. The two pages use different values on purpose. `type="email"` gives you the browser's own shape check for free, so the RPC's `isEmail` is the backstop rather than the first line of defense. Keep both: the RPC is reachable without the form. ## The shape both pages use **One form object, not a field per attribute.** `email`, `password` and `error` are one `SigninForm`, declared `private` and passed to the handler whole. A handler mutating `form.error` is live; reassigning a bare `error` attribute from inside a handler is not (`elements man html/reactivity`), so the object is what makes the error line work at all. It also keeps the constructor to one attribute as the form grows a field. **The handler is a named function, not markup.** `onSubmit(form)` holds the try/catch, the error assignment and the redirect; the tag carries `onsubmit={() => onSubmit(form)}` and nothing else. Logic inline in an attribute is logic nobody can test, reuse, or read past. **A signed-in visitor is turned away at the route, not in the page.** The route redirects before the page renders. Branching on `session.isLoggedIn()` inside the template instead paints a signed-in panel for one frame after login lands and before `redirect()` leaves, which reads as a flash; see [server rendering](../html/server). Either page still works as a deep link. ## Routes Register the pages in `index.ts` alongside the scaffold's existing structure: ```ts import signin from "#app/pages/signin"; import signup from "#app/pages/signup"; // ... app.route("/signin", signin); app.route("/signup", signup); ``` ## Notes - `session.login({ userId, userName })`. `userId` is required and marks the session as logged in. `userName` is the display name, the email here. Add a `handle` column and put that in `userName` when you want a friendlier name. - `crypt()` and `genSalt()` come from the `pgcrypto` extension. Elements installs the extension at project setup, so they're always available. - The unique constraint on `email` is the real guard against a duplicate. The RPC checks first for the friendly message; on a race the constraint throws `SqlError` and the template shows the raw Postgres message, so catch it around the insert and rethrow `AuthError` if that path matters. - Session expiry is an app-wide policy set by `session.expires` in `config.jsoc` (the scaffold sets `'30d'`). It applies to every session; `session.login()` takes only the session data (`userId`, `userName`, and any fields you declare on `SessionData`). There is no per-login expiry override.