Docs

Using modest-auth

How to add it to a Node server, call it from your pages, and handle what it sends back.

Getting started

$ npm install modest-auth

Node 24 or later, and tested on 24 and 26. The first time node:sqlite is used, Node 24 prints a warning that SQLite is experimental: that's Node's own notice about its built-in module, not an error. In Node 26, node:sqlite is a release candidate (stability 1.2).

import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { createAuth, createHandler, handleNode, toRequest } from "modest-auth";

// Its tables (auth_users, auth_sessions, auth_limits) can live in your app's own database.
const auth = createAuth(new DatabaseSync("app.db"));
// Your site's address (HTTPS), and how many reverse proxies are in front of it.
const handler = createHandler(auth, { origin: "https://example.org", proxies: 0 });

const server = createServer(async (req, res) => {
  if (await handleNode(handler, req, res)) return; // /auth/*, answered; anything else untouched
  const user = handler.userOf(toRequest(req)); // who's signed in, or null
  res.end(user ? `Hello, ${user.email}` : "Hello");
});
server.requestTimeout = 10_000; // slow clients get ten seconds, not five minutes
server.headersTimeout = 10_000;
server.listen(3000);

The routes

Every POST takes JSON, and must come from your site's own pages.

RouteBodyDoes
GET /auth/session{ user: { email } }, or { user: null }
POST /auth/sign-up{ email, password }Makes the account, and signs in
POST /auth/sign-in{ email, password }Signs in, with a fresh session (any old one ends)
POST /auth/sign-outnoneEnds this session
POST /auth/sign-out-everywherenoneEnds every session of the account
POST /auth/password{ current, next }Changes the password, and ends every other session
POST /auth/delete-account{ password }Deletes the account and its sessions

New passwords are 15 to 200 characters, any characters at all, but not a common password, not made from the email, and not with fewer than four different characters. A session lasts 30 days, or ends after 7 days unused.

From your pages

Send each route JSON with fetch, which another site's page can't do without the browser asking first. (A plain HTML form can't send JSON.)

const res = await fetch("/auth/sign-in", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ email, password }),
});
if (!res.ok) showError((await res.json()).error.message); // fit to show as it is

Your own routes

Ask who's signed in, and check that anything that changes something came from your own pages:

const request = toRequest(req); // headers only: your own reading of the body is undisturbed
const user = handler.userOf(request); // { id, email }, or null
if (req.method === "POST" && !handler.fromSite(request)) return res.writeHead(403).end();

Link your own tables to accounts, so they go when an account is deleted:

CREATE TABLE notes (id INTEGER PRIMARY KEY, user TEXT REFERENCES auth_users(id) ON DELETE CASCADE, text TEXT);

Errors

A refusal is { error: { code, message } }, with the status below. The message is fit to show. When paused or busy, Retry-After says how many seconds until it's worth trying again.

CodeStatusMeans
invalid-email422Not an email address
short-password422Fewer than 15 characters
long-password422More than 200 characters
common-password422Too easy to guess
invalid-password422Broken text (not valid Unicode)
email-taken409An account already has that email
wrong-credentials401The email and password don't match
signed-out401That needs a signed-in browser
too-many-tries429Paused: wait, then try again
busy503Too many sign-ins at once
cross-site403Not from the site's own pages
json-only415Not sent as JSON
too-large413Over 4KB
bad-json400Not a JSON object
incomplete400The request didn't arrive whole
no-address400Where it came from isn't known
not-found, post-only404, 405No such route, or not a POST
failed500Something went wrong on the server

Settings

There are three, and each is a fact about your site, not a choice of how secure to be.

origin

Your site's address, such as "https://example.org". HTTPS, except localhost for development. The cookie is worked out from it.

proxies

How many reverse proxies are in front of your server (0 if none). Only then is X-Forwarded-For believed, read from the right.

base

Where the routes live. "/auth" unless you say otherwise.

What your app does

  • Serves the site over HTTPS.
  • Declares its proxies, and lets nothing reach the server, or any proxy but the outermost, any other way.
  • Checks its own changing routes with handler.fromSite(request).
  • Sets short timeouts for slow clients.
  • Keeps scripts out of its pages (a content security policy helps).
  • Keeps passwords out of its logs, and its proxy's.
  • Keeps the database on a local disk, backs it up, and keeps the backups private.
  • Watches for refusals: a run of 429 or 503 is someone guessing.
  • Leaves /auth to the handler: every path under it is the handler's.
  • Tells people who think their session was stolen to sign out everywhere first: it needs no password.

Other servers

handler.handle(request, remoteAddress) takes a Web-standard Request and gives a Response, or null for paths that aren't its own.

// Bun
Bun.serve({ fetch: async (req, server) => (await handler.handle(req, server.requestIP(req)?.address)) ?? app(req) });

// Deno
Deno.serve(async (req, info) => (await handler.handle(req, info.remoteAddr.hostname)) ?? app(req));

// Express: first, before any body parser
app.use(async (req, res, next) => (await handleNode(handler, req, res)) || next());

Using the parts

createAuth gives the accounts, sessions, and limits without the routes. Your app then does what the handler would: checks Origin, starts a fresh session at each sign-in, and passes where each request came from.

auth.Does
signUp(email, password, from)A new account
signIn(email, password, from)The account, if the password's right
startSession(user)A session token, for a user just returned by the calls above
userOf(token)Who a session belongs to, or null
endSession(token), endAllSessions(userId)Sign out, here or everywhere
changePassword(userId, current, next, from)A new password; every session ends
deleteAccount(userId, password, from)The account and its sessions, gone

from is addressKey({ remoteAddress, forwardedFor }, proxies); refuse a request whose address comes back "unknown". Cookies come from sessionCookie(token, origin), clearedCookie(origin), and readSessionCookie(header, origin).

Limits

Counted before any password is checked, and kept in the database (so a restart doesn't reset them).

OfAt most
Tries at one account, from one address10 in 15 minutes
Tries at one account, from anywhere100 an hour
Sign-ins, sign-ups, and password checks from one address20 in 15 minutes
The same, from one IPv6 /48200 in 15 minutes
Password checks by an account's own sessions10 an hour
Passwords being checked at once2, with 50 waiting
Sessions per account50