Authentication and Authorization

00 Markdown

Authentication is knowing who a visitor is. Authorization is deciding what they may see and do. In Elements, both are your own code: users are rows in your own Postgres table, the session is readable in every route, RPC function and template, and every rule about who may do what is a line of TypeScript on the server. There is no auth service to sign up for and no user data held by anyone but you.

Users are rows in your database

Terminal
elements create migration "add users" -tables=users
app/migrations/20260509120000-add-users.migration.sql
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 );

Every Elements database has Postgres's pgcrypto extension installed, so passwords are hashed with bcrypt in the query itself, with crypt() and genSalt().

Sign up

app/pages/signup/services.ts
import { sql, session } from "@elements/app"; interface SignupForm { email: string; password: string; } /** @rpc */ export function signup(form: SignupForm) { let user = sql<User>(` insert into users ( email, passwordHash ) values ( ${form.email}, crypt(${form.password}, genSalt('bf', 12)) ) returning * `).firstOrThrow(); session.login({ userId: user.id, email: user.email, }); }

session.login() signs the visitor in. It works in an RPC function or a route, and only on the server.

Sign in

app/pages/signin/services.ts
import { sql, session, AuthError } from "@elements/app"; /** @rpc */ export function signin(email: string, password: string) { let user = sql<User>(` select * from users where email = ${email} and passwordHash = crypt(${password}, passwordHash) `).firstOrThrow(new AuthError("Wrong email or password.")); session.login({ userId: user.id, email: user.email, }); }

firstOrThrow() returns the matching user, or throws the error you give it when there is none. AuthError reaches the page with its message, ready to show next to the form.

Declare what a session holds

The data you pass to session.login() is declared once, so every read and write of it is type checked:

app/types/session.d.ts
declare module "@elements/app" { interface SessionData { userId: string; email: string; } }

session.login() now requires both fields, session.get("userId") is a string, and a misspelled key is a build error.

Read the session anywhere

session is the same object in routes, RPC functions, tests and pages. In an RPC function or a route, it is the visitor making the request:

app/pages/account/services.ts
import { sql, session } from "@elements/app"; /** @rpc */ export function myInvoices(): Invoice[] { let userId = session.getOrThrow("userId"); return sql<Invoice>(` select * from invoices where userId = ${userId} `).all(); }

session.getOrThrow() and session.isLoggedInOrThrow() turn away a visitor who is not signed in, and the page receives an AuthError.

In a template, the session is reactive. Sign in or out and every template that reads it updates at once, with no reload:

app/shared/templates/header/index.ehtml
import { session } from "@elements/app"; <Header> <span e:if={session.isLoggedIn()}>{session.get("email")}</span> <a e:else href="/signin">Sign in</a> </Header>

In a test, each test starts signed out. Call session.login() to act as a user, and every RPC function the test calls after that sees that user, the same as in the app.

Sign out

app/pages/account/services.ts
/** @rpc */ export function signout() { session.logout(); }

Authorization

Every rule about who may do what runs on the server, where the browser cannot change it. An RPC function is the only way browser code reaches your data, so its first lines are where the rules go:

app/pages/admin/services.ts
import { sql, session, ForbiddenError } from "@elements/app"; /** @rpc */ export function deleteChannel(id: string) { session.isLoggedInOrThrow(); if (!isAdmin(session.getOrThrow("userId"))) { throw new ForbiddenError(); } sql(` delete from channels where id = ${id} `); }

isLoggedInOrThrow() turns away anyone who is not signed in with an AuthError, and ForbiddenError turns away anyone without permission. The page receives each one as the same class, ready to show a message.

  • Scope queries to the user. Take the user's id from the session, never from an argument the browser sent, as myInvoices does above. A visitor can only ever read their own rows.
  • Check in the route, too. A route that renders a private page checks the session before it reads anything, and can redirect("/signin") instead.
  • Live data follows the same rule. The route or RPC function that opens a LiveTable view decides who gets those rows, and the LiveTable's write handlers decide who may change them.
  • Test the rules. A test can sign in a user with session.login(), call the RPC function and assert that it throws ForbiddenError.

How sessions work

  • A session is a row in your database. The browser holds an opaque token in a cookie, and every request checks it against that row.
  • It slides. A session lasts 30 days by default, and every request pushes that back, so active users stay signed in. Change the length in config.jsoc, or set it to end when the browser closes.
  • Signing out ends it everywhere it matters. session.logout() deletes the row, and live data opened for that user stops. You can also list a user's sessions on other devices and end any of them.
  • A visitor who is not signed in has no session, no row and no cookie.

The authentication recipe (opens in a new tab) is a complete sign up and sign in, with both pages.

The manual: session (opens in a new tab), rpc (opens in a new tab), livetable (opens in a new tab), recipes (opens in a new tab).

Get a digest to your inbox once per week.

Comments · 0