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(),
handle 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 handle (unique, so no two
users share one) and passwordHash (stores the bcrypt hash, never the
plaintext).
Shared auth rpc
app/shared/services/auth.ts:
import { sql, session, AuthError } from "@elements/app";
interface User {
id: string;
handle: string;
}
/** @rpc */
export function signinUser(handle: string, password: string) {
let user = sql<User>(
`select id, handle from users
where handle = ${handle}
and passwordHash = crypt(${password}, passwordHash)`,
).first();
if (!user) {
throw new AuthError("invalid handle or password");
}
session.login({ userId: user.id, userName: user.handle });
}
/** @rpc */
export function signupUser(handle: string, password: string) {
let user = sql<{ id: string }>(
`insert into users (handle, passwordHash)
values (${handle}, crypt(${password}, genSalt('bf', 12)))
returning id`,
).firstOrThrow();
session.login({ userId: user.id, userName: handle });
}
/** @rpc */
export function logoutUser() {
session.logout();
}
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.
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.html with the contents below.
app/pages/signin/index.ts:
import signin from "#app/pages/signin/template";
export default function route(req, res) {
return new signin();
}
app/pages/signin/template.html:
import "./style.css";
import { session } from "@elements/app";
import { signinUser, logoutUser } from "#app/shared/services/auth";
function attemptSignin(handle: string, password: string): string {
try {
signinUser(handle, password);
return "";
} catch (err: any) {
return err.message;
}
}
<html class="signin"
(private handle: string = "",
private password: string = "",
private error: string = "")>
<div e:if={session.isLoggedIn()}>
<p>welcome, {session.get('userName')}</p>
<button onclick={() => logoutUser()}>log out</button>
</div>
<form e:else onsubmit={() => {
error = attemptSignin(handle, password);
if (!error) {
handle = "";
password = "";
}
}}>
<h1>sign in</h1>
<input type="text" placeholder="handle" value={handle} required>
<input type="password" placeholder="password" value={password} required>
<button type="submit">sign in</button>
<p e:if={error} class="error">{error}</p>
<a href="/signup">need an account? sign up</a>
</form>
</html>
Signup page
elements create page signup
Update app/pages/signup/index.ts and template.html.
app/pages/signup/index.ts:
import signup from "#app/pages/signup/template";
export default function route(req, res) {
return new signup();
}
app/pages/signup/template.html:
import "./style.css";
import { session } from "@elements/app";
import { signupUser, logoutUser } from "#app/shared/services/auth";
function attemptSignup(handle: string, password: string): string {
try {
signupUser(handle, password);
return "";
} catch (err: any) {
return err.message;
}
}
<html class="signup"
(private handle: string = "",
private password: string = "",
private error: string = "")>
<div e:if={session.isLoggedIn()}>
<p>welcome, {session.get('userName')}</p>
<button onclick={() => logoutUser()}>log out</button>
</div>
<form e:else onsubmit={() => {
error = attemptSignup(handle, password);
if (!error) {
handle = "";
password = "";
}
}}>
<h1>sign up</h1>
<input type="text" placeholder="handle" value={handle} required>
<input type="password" placeholder="password" value={password} required>
<button type="submit">sign up</button>
<p e:if={error} class="error">{error}</p>
<a href="/signin">have an account? sign in</a>
</form>
</html>
session.isLoggedIn() is reactive in the browser. The moment a signin or signup
rpc returns successfully, the welcome branch renders without a page reload.
Either page works as a deep link: the user lands on /signin or /signup
directly and follows the link if they picked the wrong one.
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. For this recipe the two are different: id is a uuid, the handle is human-readable.crypt()andgenSalt()come from thepgcryptoextension. Elements installs the extension at project setup, so they're always available.- The unique constraint on
handlemeans a signup with a taken name throws aSqlError. The current template surfaces the raw Postgres message. For a friendlier UX, catch in the rpc and rethrow with a clearerAuthError("that handle is taken"). - 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.