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.
| Route | Body | Does |
|---|---|---|
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-out | none | Ends this session |
POST /auth/sign-out-everywhere | none | Ends 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.
| Code | Status | Means |
|---|---|---|
invalid-email | 422 | Not an email address |
short-password | 422 | Fewer than 15 characters |
long-password | 422 | More than 200 characters |
common-password | 422 | Too easy to guess |
invalid-password | 422 | Broken text (not valid Unicode) |
email-taken | 409 | An account already has that email |
wrong-credentials | 401 | The email and password don't match |
signed-out | 401 | That needs a signed-in browser |
too-many-tries | 429 | Paused: wait, then try again |
busy | 503 | Too many sign-ins at once |
cross-site | 403 | Not from the site's own pages |
json-only | 415 | Not sent as JSON |
too-large | 413 | Over 4KB |
bad-json | 400 | Not a JSON object |
incomplete | 400 | The request didn't arrive whole |
no-address | 400 | Where it came from isn't known |
not-found, post-only | 404, 405 | No such route, or not a POST |
failed | 500 | Something 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
/authto 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).
| Of | At most |
|---|---|
| Tries at one account, from one address | 10 in 15 minutes |
| Tries at one account, from anywhere | 100 an hour |
| Sign-ins, sign-ups, and password checks from one address | 20 in 15 minutes |
| The same, from one IPv6 /48 | 200 in 15 minutes |
| Password checks by an account's own sessions | 10 an hour |
| Passwords being checked at once | 2, with 50 waiting |
| Sessions per account | 50 |