Manual Recipes Authentication

Authentication

elements man recipes/authentication Read as markdown

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

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