Getting started

Add sign-in to a Node site

From an empty folder to a site where people sign up, sign in, and see a members' page, with Node's own server and nothing else. Then what to change to go live.

Before you start

You need Node 24 or later (node -v says which you have). You don't need an email service yet: until you go live, each email is printed in the terminal instead, link and all.

You'll make a server and four files for the browser. People sign up with their email: a link is emailed to them, and opening it is where they choose a password. After that, they sign in with the password, or with a link emailed when they ask.

Set up

$ mkdir my-site && cd my-site
$ npm init -y
$ npm pkg set type=module
$ npm install modest-auth --save-exact
$ mkdir public

type=module lets server.js use import. --save-exact keeps the version you installed until you choose to upgrade: modest-auth is an early release, so read the changelog before you do.

The server

Make server.js:

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

// The address people open the site at.
const ORIGIN = process.env.ORIGIN ?? "http://localhost:3000";

// Accounts, sessions, and limits, kept in one SQLite file.
const auth = createAuth(new DatabaseSync(process.env.DATABASE_PATH ?? "app.db"));

// The routes under /auth, which your pages call.
const handler = createHandler(auth, {
  origin: ORIGIN,
  // Your page that emailed links open.
  linkPage: "/link",
  // For now, print each email (with its link) here, rather than send it.
  sendEmail: (to, subject, text) => console.log(`\nTo: ${to}\nSubject: ${subject}\n\n${text}\n`),
});

// The site's own files: only these, each read once.
const file = (path, type) => ({ body: readFileSync(path, "utf8"), type });
const files = {
  "/": file("public/index.html", "text/html"),
  "/link": file("public/link.html", "text/html"),
  "/app.js": file("public/app.js", "text/javascript"),
  "/link.js": file("public/link.js", "text/javascript"),
};

const headers = (type) => ({
  "content-type": `${type}; charset=utf-8`,
  // Only this site's own scripts run on its pages.
  "content-security-policy": "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'",
  "referrer-policy": "no-referrer",
});

const server = createServer(async (req, res) => {
  try {
    // Anything under /auth is answered here.
    if (await handleNode(handler, req, res)) return;

    // A page only for people signed in.
    if (req.url === "/members") {
      const user = handler.userOf(toRequest(req));
      if (!user) return res.writeHead(303, { location: "/" }).end();
      return res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end(`Welcome to the members' page, ${user.email}.`);
    }

    const found = files[req.url];
    if (!found) return res.writeHead(404, headers("text/plain")).end("Not found");
    res.writeHead(200, headers(found.type)).end(found.body);
  } catch (error) {
    console.error(error);
    res.writeHead(500).end("Something went wrong.");
  }
});
// Slow clients get ten seconds, not Node's five minutes.
server.requestTimeout = 10_000;
server.headersTimeout = 10_000;
server.listen(3000, () => console.log(`Open ${ORIGIN}`));
  • createAuth keeps accounts, sessions, and limits in app.db, a SQLite file it sets up the first time.
  • createHandler answers every request under /auth: signing up, signing in, signing out, and the rest. handleNode gives it each request first; anything else is left to you.
  • sendEmail is how emails go out. Here it prints them; going live swaps in an email service.
  • Only the files listed are served, so nothing else in the folder (such as app.db) can be fetched.
  • The content-security-policy header lets only the site's own scripts run on its pages.

The home page

Make public/index.html, with a form for each thing someone can do:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My site</title>
  </head>
  <body>
    <h1 id="who">…</h1>
    <p id="status" role="status"></p>

    <div id="signed-out" hidden>
      <h2>Sign up, or forgot your password?</h2>
      <form method="post" data-route="send-password-link">
        <label>Email <input name="email" type="email" autocomplete="email" required /></label>
        <button>Email me a link</button>
      </form>

      <h2>Sign in</h2>
      <form method="post" data-route="sign-in">
        <label>Email <input name="email" type="email" autocomplete="username" required /></label>
        <label>Password <input name="password" type="password" autocomplete="current-password" required /></label>
        <button>Sign in</button>
      </form>
      <form method="post" data-route="send-sign-in-link">
        <label>Email <input name="email" type="email" autocomplete="email" required /></label>
        <button>Email me a sign-in link instead</button>
      </form>
    </div>

    <div id="signed-in" hidden>
      <p><a href="/members">The members' page</a></p>
      <form method="post" data-route="sign-out"><button>Sign out</button></form>

      <h2>Change your password</h2>
      <form method="post" data-route="password">
        <label>Current password <input name="current" type="password" autocomplete="current-password" required /></label>
        <label>New password <input name="next" type="password" autocomplete="new-password" required minlength="15" /></label>
        <button>Change it</button>
      </form>
    </div>

    <script type="module" src="/app.js"></script>
  </body>
</html>

And public/app.js, which sends each form to its route:

// Sends a route JSON, as every /auth route expects.
async function post(route, body) {
  const res = await fetch(`/auth/${route}`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  return { res, json: await res.json() };
}
const say = (text) => (document.getElementById("status").textContent = text);

// Who's signed in, if anyone.
const { user } = await (await fetch("/auth/session")).json();
document.getElementById("who").textContent = user ? `Signed in as ${user.email}` : "Not signed in";
document.getElementById(user ? "signed-in" : "signed-out").hidden = false;

// Each form sends its fields to the route named in its data-route.
for (const form of document.querySelectorAll("form")) {
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const { res, json } = await post(form.dataset.route, Object.fromEntries(new FormData(form)));
    if (res.status === 202) say("Check your email for a link.");
    else if (res.ok) location.reload();
    else say(json.error.message);
  });
}
  • Every route takes JSON, sent with fetch. Another site's page can't send that without the browser asking first, which is part of how the routes know a request came from your own pages.
  • Each form's fields are named as its route expects: email and password to sign in, current and next to change the password. The routes list them all.
  • The forms are method="post" so that, should the script fail to load, a password never ends up in an address.
  • A refusal's message is fit to show as it is, such as "That email and password don't match."
  • Asking for a link always answers the same, whether or not the email has an account, so nobody can use the form to find out who has one.

Each emailed link opens /link, with a token after the #, such as /link#password=…. Make public/link.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My site</title>
  </head>
  <body>
    <h1 id="for">Checking the link…</h1>
    <p id="status" role="status"></p>

    <form method="post" data-route="set-password" hidden>
      <label>New password <input name="password" type="password" autocomplete="new-password" required minlength="15" /></label>
      <button>Save and sign in</button>
    </form>
    <form method="post" data-route="sign-in-with-link" hidden>
      <button>Sign in</button>
    </form>

    <script type="module" src="/link.js"></script>
  </body>
</html>

And public/link.js:

// The token comes after the "#", which browsers never send to a server.
// Read it first, then take it out of the address bar.
const token = location.hash.slice(1).split("=")[1];
history.replaceState(null, "", location.pathname);

async function post(route, body) {
  const res = await fetch(`/auth/${route}`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  return { res, json: await res.json() };
}
const heading = document.getElementById("for");

// Which email is this link for? Say so, so nobody is signed in to an account they didn't expect.
const link = await post("link", { token });
if (!link.res.ok) {
  heading.textContent = link.json.error.message;
} else {
  const { kind, email } = link.json;
  const route = kind === "password" ? "set-password" : "sign-in-with-link";
  heading.textContent = `${kind === "password" ? "Choose a password for" : "Sign in as"} ${email}`;
  const form = document.querySelector(`[data-route="${route}"]`);
  form.hidden = false;

  // Nothing happens until the person presses the button.
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const { res, json } = await post(route, { ...Object.fromEntries(new FormData(form)), token });
    if (res.ok) location.href = "/";
    else document.getElementById("status").textContent = json.error.message;
  });
}

Three things here keep the links safe, so keep them when you change the page:

  1. The token is read first, then taken out of the address bar. What follows # is never sent to a server, so the token stays out of logs. Keep analytics and other sites' scripts off this page.
  2. It says which email the link is for before anything happens.
  3. Nothing happens until the person presses the button. Someone could send a person a link to the sender's own account, to sign them in to it unawares; seeing the email is what lets them notice.

Try it

$ node server.js

Open http://localhost:3000. Use localhost, not 127.0.0.1: the routes only answer the site's own address, as set in ORIGIN. On Node 24, the terminal first shows a warning that SQLite is experimental. That's Node's notice about its built-in module, not an error.

  1. Sign up: enter an email under "Sign up" and press the button. The email appears in the terminal.
  2. Open the link from the terminal in the same browser, all of it, including the part after #.
  3. Choose a password of at least 15 characters. A few unrelated words together works well; common passwords are refused.
  4. You're signed in. Open the members' page, sign out, then sign in again with the password, or with a link emailed by "Email me a sign-in link instead".

While you try things:

  • Each link works once. Opened again, it says it has expired or been used.
  • Only 3 links of each kind go to one address in 15 minutes. Past that, nothing is printed, but the page still says to check your email (as it does for an address with no account). Use another address, or wait.
  • "Too many tries" means the limits for one address have been reached: 20 sign-ins, links, and password checks in 15 minutes. While you're developing, you can start over by stopping the server and deleting the database: rm app.db*.

Your own pages

The members' page in server.js shows how to tell who's signed in: handler.userOf(toRequest(req)) gives { id, email }, or null.

A route of your own that changes something, such as saving a post, should also check the request came from your own pages, as the /auth routes do:

const request = toRequest(req);
const user = handler.userOf(request);
if (!user) return res.writeHead(401).end();
if (!handler.fromSite(request)) return res.writeHead(403).end();

Link your own tables to accounts, so a member's things go when their account is deleted: user TEXT REFERENCES auth_users(id) ON DELETE CASCADE.

The other routes are each one more form in index.html, with its fields named as the route expects. To let people delete their account:

<form method="post" data-route="delete-account">
  <label>Password <input name="password" type="password" autocomplete="current-password" required /></label>
  <button>Delete my account</button>
</form>

Changing the email also needs two more kinds of link on the link page: see the link page in the docs.

Going live

Four things change: the address, the email, a check on new passwords, and the proxy in front. In server.js, from // Accounts to the end of createHandler:

// Accounts, sessions, and limits, kept in one SQLite file.
const auth = createAuth(new DatabaseSync(process.env.DATABASE_PATH ?? "app.db"), {
  // Refuse passwords known from data breaches. Only the first 5 characters of a hash leave.
  breachedRange: (prefix, { signal }) =>
    fetch(`https://api.pwnedpasswords.com/range/${prefix}`, { headers: { "add-padding": "true" }, signal }),
});

// Sends an email with Resend (any email service with an HTTPS API works much the same).
async function sendWithResend(to, subject, text, { signal }) {
  const res = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { authorization: `Bearer ${process.env.RESEND_API_KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ from: "My site <signin@example.org>", to, subject, text }),
    signal,
  });
  if (!res.ok) throw new Error(`Resend answered ${res.status}`);
}

// The routes under /auth, which your pages call.
const handler = createHandler(auth, {
  origin: ORIGIN,
  // Your page that emailed links open.
  linkPage: "/link",
  // Sent for real when there's a key; printed here when there isn't.
  sendEmail: process.env.RESEND_API_KEY
    ? sendWithResend
    : (to, subject, text) => console.log(`\nTo: ${to}\nSubject: ${subject}\n\n${text}\n`),
  // The proxy in front: here, nginx or Caddy on the same machine. Leave it out if nothing's in front.
  trustedProxies: ["loopback"],
});
  • Serve the site over HTTPS, and set ORIGIN to its address, such as https://example.org. The handler won't start with a plain http:// address other than localhost.
  • Send email with a service, such as Resend, Postmark, or Amazon SES. Verify your sending domain with it (SPF, DKIM, and DMARC records), or the emails land in spam. Keep the key in an environment variable, and don't print emails once live: they hold the links.
  • Refuse breached passwords with breachedRange, as above. Have I Been Pwned's range service is free and needs no key, and only the first 5 characters of the password's hash are sent. If it doesn't answer, the password is allowed, and the log says so.
  • Name the proxy in front, if any, in trustedProxies: ["loopback"] for nginx or Caddy on the same machine, ["private"] for a hosting platform's load balancer, and nothing at all if nothing's in front. nginx also needs proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. For a CDN, see Behind a proxy.
  • Keep the database on a persistent disk, outside the code, so a deploy doesn't wipe it, and set DATABASE_PATH to it.
  • Back the database up on a schedule, and keep the backups as private as the server: sqlite3 app.db ".backup 'backup.db'" copies it safely while the site runs.
  • Read the log after the first deploy. Setup mistakes, such as a proxy that isn't named, are said in lines starting modest-auth:, with what to set.

Then start it with the settings:

$ ORIGIN=https://example.org DATABASE_PATH=/var/lib/my-site/app.db RESEND_API_KEY=re_… node server.js

Next, the docs cover every route, error, and setting, and security covers what modest-auth protects against, and what's left to your app.