diff --git a/docs.json b/docs.json
index 33d8f5a..8e00cd5 100644
--- a/docs.json
+++ b/docs.json
@@ -84,6 +84,7 @@
{
"group": "Security",
"pages": [
+ "security/sign-in",
"security/api-keys",
"security/tls",
"security/rate-limits",
diff --git a/security/api-keys.mdx b/security/api-keys.mdx
index 363d592..d2e7aed 100644
--- a/security/api-keys.mdx
+++ b/security/api-keys.mdx
@@ -20,6 +20,13 @@ OpenAI-compatible API and every management route (load a model, unload one,
key answers everyone who can reach it.
+
+**A key is for programs. A person signs in instead.** With auth on, the dashboard
+takes a name and a password and keeps a session, so nobody has to paste a shared
+secret into a browser. See [Sign in and accounts](/security/sign-in). Everything
+on this page still applies to every program that calls this node.
+
+
## What the installer does
| Situation | What happens |
@@ -232,14 +239,16 @@ A wrong key gets a 401 with `Invalid API key`.
## The rule, and the paths that stay open
-**With auth enabled, every path under `/api` and `/v1` needs the key.** There are
-five exceptions and the static shell, each with a reason:
+**With auth enabled, every path under `/api` and `/v1` needs the key.** The
+exceptions are the static shell and these paths, each with a reason:
| Path | Why it stays open |
|---|---|
| `/` and `/static/*` | The shell is files. Without it the browser cannot render the page that asks for a key |
| `GET /api/health` | A liveness probe has no key. It carries the running `version`, which is what `ainode update` reads to verify a release on a node that requires one |
| `GET /api/auth/status` | So the UI can say "this node wants a key" instead of rendering an empty page |
+| `GET /api/auth/me` | So the page can ask whether somebody is signed in. See [Sign in and accounts](/security/sign-in) |
+| `POST /api/auth/login` | The request that signs a person in cannot need a session first |
| `GET /api/cluster/endpoint` | Node names, addresses and ports, nothing else. A client whose configured node is down has to be able to ask a reachable one where the rest of the fleet is, and the key it holds does not help it find an address. No model, no telemetry, no config, no key material |
| `POST /api/cluster/join` | A node joining this cluster does not hold this cluster's key yet, so a single-use expiring [join token](/cluster/joining) is the credential instead. The route is rate limited to five attempts a minute per source address in place of a key |
diff --git a/security/sign-in.mdx b/security/sign-in.mdx
new file mode 100644
index 0000000..7e2cbc1
--- /dev/null
+++ b/security/sign-in.mdx
@@ -0,0 +1,201 @@
+---
+title: "Sign in and accounts"
+description: "People sign in to the dashboard with a name and a password. Programs keep using an API key. The first account, the account page, managing users, and how to get back in."
+icon: "user-lock"
+---
+
+With auth on, the dashboard asks a person to sign in. A name, a password, and the
+session lasts until you sign out or until an admin revokes it.
+
+Programs are unchanged. A coding agent, [AINode Desktop](/desktop), the bench and
+curl all keep presenting an API key as `Authorization: Bearer `.
+
+## Why there is a login
+
+An API key is a machine credential. It protects the port, and that is all it
+does. Pasted into a browser it is one shared secret: the node cannot tell one
+person from another, and revoking the key revokes everyone holding it.
+
+A login is the part that belongs to a person. Each person has their own name and
+password, their sessions are their own, and an admin can take one person's access
+away without touching anybody else.
+
+| Caller | What it presents |
+|---|---|
+| A person on the dashboard | A name and a password, then a session |
+| A program: a coding agent such as dsh or Pi, [AINode Desktop](/desktop), the bench, curl | An API key, as `Authorization: Bearer ` |
+
+Both work at the same time on the same node. [API keys](/security/api-keys)
+covers the key half.
+
+## The first account
+
+Accounts are made on the **master node**, from the CLI, because the first one
+cannot be made from a page that needs an account to open.
+
+```bash
+ainode auth user add --admin
+```
+
+It prompts for the password. In a script, `--password-stdin` reads it from the
+pipe instead:
+
+```bash
+printf '%s' "$PASSWORD" | ainode auth user add --admin --password-stdin
+```
+
+If the node does not require auth yet, turn it on:
+
+```bash
+ainode auth enable
+```
+
+
+Make the first account **before** you turn auth on. A node that requires auth
+with no account is a node with a sign-in nobody can pass, and the way out is the
+same command above, typed on the node itself.
+
+
+### On a fresh node
+
+A fresh install already requires an API key, so `ainode auth enable` has nothing
+to do (see [API keys](/security/api-keys)). One command is the whole setup:
+
+```bash
+ainode auth user add --admin
+```
+
+Then open the dashboard and sign in.
+
+### On a fleet that is already running
+
+Run the same commands on the **master**. Accounts are managed there and
+replicated to every node in the cluster automatically, so you add a person once
+and they can sign in on any node.
+
+**Sessions are per node.** A replicated account is one account, but signing in
+creates a session on the node whose dashboard you opened. Open another node's
+dashboard and you sign in again.
+
+## Signing in
+
+Open the node's dashboard and type your name and password. That is the whole
+flow. The session lasts until you sign out, or until an admin revokes it. A
+revoked session ends wherever it was open.
+
+## Your account page
+
+Yours whatever your role:
+
+- **Change your password.**
+- **See your sessions and revoke them**, one per browser you have signed in from.
+ A machine you left signed in somewhere else is one click to end.
+
+## Managing users
+
+Admins get a **Users** page: add an account, remove one, reset somebody's
+password, disable an account and enable it again. Disabling keeps the account and
+refuses the sign-in, which is what you want for somebody who is coming back.
+
+| Role | What it can do |
+|---|---|
+| `admin` | Manage users, manage API keys, and turn the auth switch on and off. Everything a member can do as well |
+| `member` | Use the dashboard |
+
+Two roles, and that is the whole list.
+
+## From the CLI
+
+```bash
+ainode auth user add --admin # an admin account. Prompts for the password
+ainode auth user add # a member account
+ainode auth user list # the accounts that exist
+ainode auth user passwd # set somebody a new password
+ainode auth user disable # keep the account, refuse the sign-in
+ainode auth user enable # let them back in
+ainode auth user remove # delete the account
+
+ainode auth session list # who is signed in
+ainode auth session revoke # end one session
+ainode auth session clear # end all of them
+
+ainode auth status # the switch, the keys, and now the users
+ # and the sessions as well
+```
+
+Every one of them takes `--help` for its exact arguments. Run the `user` commands
+on the master, since that is where accounts are managed. The `ainode auth key`
+commands are unchanged and live on [API keys](/security/api-keys).
+
+## Serve it over HTTPS
+
+A sign-in over plain HTTP sends the password in the clear. Anything on the path
+between the browser and the node can read it, and on a LAN that is more machines
+than you think. So put the node behind HTTPS before you hand out accounts.
+
+[TLS](/security/tls) is one command. It serves HTTPS on its own port and leaves
+plain HTTP exactly where it was, so nothing else in the fleet has to change.
+
+The session cookie is marked **Secure** when the node is served over HTTPS, so on
+that port the browser will not send it back over a plain HTTP request.
+
+## What is stored, and where
+
+| File | What is in it |
+|---|---|
+| `/users.json` | The accounts. Password **hashes** only, never a password. Mode 0600 |
+| `/auth.json` | The API keys, hashed, and whether this node requires one |
+
+`` is `~/.ainode` unless you moved it. Passwords are hashed with
+scrypt.
+
+## What is in place
+
+One line each:
+
+- **The session cookie is HttpOnly**, so no script on the page can read it.
+- **It is marked Secure over HTTPS**, so it does not travel on a plain HTTP
+ request to that node.
+- **Every write from the dashboard carries `X-AINode-Client: dashboard`**, a
+ header a browser will not attach to a cross-site request. That is the CSRF
+ guard: another site can make your browser send your cookie, it cannot make it
+ send that header.
+- **Ten failed sign-ins in five minutes pause that address.** The pause lifts on
+ its own, and how long it lasts is described in the release notes.
+- **Passwords are scrypt-hashed**, so the file holds no password to steal.
+
+## The paths that stay open
+
+With auth on, everything under `/api` and `/v1` needs a credential. The
+exceptions are the static shell and these paths, two of which the sign-in itself
+needs:
+
+| Path | Why it stays open |
+|---|---|
+| `/` and `/static/*` | The shell is files. Without it the browser cannot render the page that asks you to sign in |
+| `/api/health` | A liveness probe holds no credential |
+| `/api/auth/status` | So the page can say this node wants one, instead of rendering blank |
+| `/api/auth/me` | So the page can ask whether you are signed in |
+| `/api/auth/login` | The request that signs you in cannot need a session first |
+| `/api/cluster/endpoint` | Names, addresses and ports, so a client stranded by its own node can find a reachable one |
+| `POST /api/cluster/join` | A node joining this cluster holds none of this cluster's credentials yet, so a single-use [join token](/cluster/joining) is the credential instead |
+
+Matching is on the path, for every method.
+[API keys](/security/api-keys) carries the rest of the rule.
+
+## What `ainode doctor` says
+
+`ainode doctor` has a **Login** check, printed with the other auth lines. What it
+verdicts on is described in the release notes. See [Doctor](/ops/doctor) for the
+rest of the report.
+
+## Troubleshooting
+
+| What happened | What to do |
+|---|---|
+| **Locked out.** The password is gone, and so is the only admin's | On the master, at the node's own terminal: `ainode auth user passwd `. The CLI runs on the node, so it needs no session |
+| **No accounts yet**, and the node wants a sign-in | `ainode auth user add --admin` on the master, then sign in |
+| **You were signed in and now you are not** | The session ended: you signed out, or an admin revoked it. Sign in again. `ainode auth session list` shows which sessions are still live |
+| **Too many wrong passwords** | Ten failed sign-ins in five minutes pause that address. Wait, then try again |
+| **A program started answering 401** | Programs do not sign in, they present a key. Mint one with `ainode auth key create --name `: [API keys](/security/api-keys) |
+| **You can sign in on one node and not another** | Sessions are per node, so each dashboard asks once. If the account itself is not on that node, check the node actually joined the cluster: [Joining a node](/cluster/joining) |