--- title: "Authentication and Authorization" author: "@chris" author_url: https://elements.dev/u/chris published: 2026-10-02T15:11:56.338Z url: https://elements.dev/feed/01a0fd2c-0330-7631-a51e-44b2d1d72393 kind: lesson format: article --- # Authentication and Authorization by [@chris](https://elements.dev/u/chris) ยท 2026-10-02 ## Description User accounts in your own database, passwords hashed with bcrypt, sessions in routes, RPC functions, tests and pages, and rules for what each user may do. 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 ```bash Terminal elements create migration "add users" -tables=users ``` ```sql 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 ```typescript 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(` 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 ```typescript app/pages/signin/services.ts import { sql, session, AuthError } from "@elements/app"; /** @rpc */ export function signin(email: string, password: string) { let user = sql(` 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: ```typescript 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: ```typescript app/pages/account/services.ts import { sql, session } from "@elements/app"; /** @rpc */ export function myInvoices(): Invoice[] { let userId = session.getOrThrow("userId"); return sql(` 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: ```ehtml app/shared/templates/header/index.ehtml import { session } from "@elements/app";
{session.get("email")} Sign in
``` 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 ```typescript 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: ```typescript 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](/learn/man/recipes/authentication) is a complete sign up and sign in, with both pages. The manual: [session](/learn/man/session), [rpc](/learn/man/rpc), [livetable](/learn/man/livetable), [recipes](/learn/man/recipes).