Authentication
elements man recipes/authentication Read as markdownForm-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
elements create migration 'add users' -tables=users
app/migrations/<timestamp>-add-users.migration.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:
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<User>(
`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:
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:
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
elements create page signin
Update app/pages/signin/index.ts and template.ehtml with the contents below.
app/pages/signin/index.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:
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;
}
}
<html class="page-signin" (
private form: SigninForm = { email: "", password: "", error: "" },
)>
<main class="page-shell">
<form class="stack" onsubmit={() => onSubmit(form)}>
<h1>sign in</h1>
<p e:if={form.error} class="error" role="alert">{form.error}</p>
<div class="field">
<label for="email">email</label>
<input id="email"
name="email"
type="email"
value={form.email}
autocomplete="email"
autocapitalize="none"
spellcheck={false}
placeholder="ada@example.com"
required>
</div>
<div class="field">
<label for="password">password</label>
<input id="password"
name="password"
type="password"
value={form.password}
autocomplete="current-password"
required>
</div>
<button type="submit" class="is-primary">sign in</button>
<a href="/signup">need an account? sign up</a>
</form>
</main>
</html>
Signup page
elements create page signup
Update app/pages/signup/index.ts and template.ehtml.
app/pages/signup/index.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:
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;
}
}
<html class="page-signup" (
private form: SignupForm = { email: "", password: "", error: "" },
)>
<main class="page-shell">
<form class="stack" onsubmit={() => onSubmit(form)}>
<h1>sign up</h1>
<p e:if={form.error} class="error" role="alert">{form.error}</p>
<div class="field">
<label for="email">email</label>
<input id="email"
name="email"
type="email"
value={form.email}
autocomplete="email"
autocapitalize="none"
spellcheck={false}
placeholder="ada@example.com"
required>
</div>
<div class="field">
<label for="password">password</label>
<input id="password"
name="password"
type="password"
value={form.password}
autocomplete="new-password"
minlength={MIN_PASSWORD}
required>
<small>at least {MIN_PASSWORD} characters</small>
</div>
<button type="submit" class="is-primary">create account</button>
<a href="/signin">have an account? sign in</a>
</form>
</main>
</html>
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. Either page still works as a deep link.
Routes
Register the pages in index.ts alongside the scaffold's existing structure:
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 }).userIdis required and marks the session as logged in.userNameis the display name, the email here. Add ahandlecolumn and put that inuserNamewhen you want a friendlier name.crypt()andgenSalt()come from thepgcryptoextension. Elements installs the extension at project setup, so they're always available.- The unique constraint on
emailis the real guard against a duplicate. The RPC checks first for the friendly message; on a race the constraint throwsSqlErrorand the template shows the raw Postgres message, so catch it around the insert and rethrowAuthErrorif that path matters. - Session expiry is an app-wide policy set by
session.expiresinconfig.jsoc(the scaffold sets'30d'). It applies to every session;session.login()takes only the session data (userId,userName, and any fields you declare onSessionData). There is no per-login expiry override.