Authentication
Single administrator. Password plus a mandatory second factor (TOTP or passkey); a passkey with user
verification is also accepted on its own. Source: apps/server/src/security/*,
apps/server/src/modules/auth/*; web pages apps/web/src/app/(auth)/{setup,login}.
The threat model and the test mapping are in security.md.
Route authorisation levels
One global onRequest hook (security/guard.ts) applies to every path under /api/, including WebSocket
upgrades. A route is full unless it opts down.
| Level | Requirement | Routes |
|---|---|---|
public |
none | GET /health |
preauth |
none to read; POSTs need Origin = CR_PUBLIC_ORIGIN and Content-Type: application/json |
GET /auth/status, POST /auth/setup, POST /auth/login, POST /auth/passkey/login/options, POST /auth/passkey/login/verify |
restricted |
a valid session, second factor possibly pending; mutations need Origin + CSRF token | POST /auth/totp, POST /auth/logout, TOTP enrolment (`/auth/totp/enroll/start |
full |
a valid session with a completed second factor; mutations need Origin + CSRF token; WebSocket upgrades need Origin | everything else |
Failures: 401 unauthenticated, 403 second_factor_required, 403 bad_origin, 403 bad_csrf,
403 bad_content_type. Unknown API paths answer a JSON 404.
First-run setup
- While the
userstable is empty, the server writes a random token (32 bytes, base64url) to$CR_DATA_DIR/setup-token(mode 0600) at every start. It is never logged. A server restart before setup replaces the token, so read it just before use. - The owner reads it on the host (
ssh M5M36 'cat ~/claude-remote-data/setup-token') and opens/setup/. POST /auth/setupwith{setupToken, username, password}: username 3–32 characters ([a-z0-9_.-], stored lower-case), password 12–256 characters. A wrong token is 401invalid_setup_tokenand counts as a failure for the caller's IP.- The administrator row is created under an advisory lock (two concurrent setups cannot both succeed),
the token file is deleted, and a restricted session is issued. The response is
{ok: true, next: "enroll_second_factor"}. - The setup page then requires TOTP or passkey enrolment. Until a second factor exists, the session can
only reach the
restrictedroutes. - Once an administrator exists,
POST /auth/setupanswers 404 permanently, and the token file is removed at every start.
Login flows
POST /auth/login returns a LoginStep ({ok, next}):
| Situation | next |
Session issued |
|---|---|---|
| Wrong username or password | — (401 invalid_credentials; same answer and same work for an unknown user) |
none |
| Password correct, no second factor enrolled | enroll_second_factor |
restricted |
Password correct, body also carries a valid totp |
done |
full |
| Password correct, TOTP enrolled | totp → POST /auth/totp {code} |
restricted, upgraded on success |
| Password correct, only passkeys enrolled | passkey → passkey login options/verify |
restricted, upgraded on success |
Passkey-first login (no password): POST /auth/passkey/login/options without a session returns options
for discoverable credentials (no credential ids are disclosed) and sets a short-lived cookie
__Host-cr_pre; the challenge is stored server-side in preauth_challenges, keyed by the hash of that
cookie. POST /auth/passkey/login/verify consumes the challenge and, on a valid assertion with user
verification, issues a full session.
When a restricted session is upgraded, its token and CSRF token are replaced (new cookie). When a new session is issued to a caller that already presents one, the old one is revoked.
Second factors
| Factor | Details |
|---|---|
| TOTP | RFC 6238, HMAC-SHA1, 6 digits, 30 s step, ±1 step accepted. Secret generated server-side, shown once (secret + otpauth:// URL; the web client renders the QR code locally). Enrolment must be confirmed with a valid code within 10 minutes. Stored sealed with AES-256-GCM (CR_SECRET_KEY), bound to the user id. Each time-step counter is accepted once (totp_last_counter, conditional update). |
| Passkey | @simplewebauthn/server v14. RP ID = hostname of CR_PUBLIC_ORIGIN, expected origin = CR_PUBLIC_ORIGIN, resident key and user verification required. Challenges are server-side, single-use, valid 5 minutes. Signature counter stored and checked. Several passkeys can be registered, each with a label. |
Rules enforced by the server:
- Enrolling a new factor requires a full session — or a restricted one only while the account has no factor at all (first run). A password alone can never attach a factor to an account that already has one.
- The last remaining second factor cannot be removed (
409 last_second_factor):DELETE /auth/totprequires at least one passkey; deleting a passkey requires TOTP or another passkey.
Sessions and cookies
| Property | Value |
|---|---|
| Cookie | __Host-cr_sid — HttpOnly, Secure, SameSite=Strict, Path=/, no Domain. Outside production on http://localhost only, the name is cr_sid and Secure is off. |
| Token | 32 random bytes; only its SHA-256 is stored (auth_sessions.token_hash) |
| CSRF token | 24 random bytes per session, returned by GET /auth/status, sent as X-CSRF-Token; the web client keeps it in memory only |
| Idle timeout | 12 hours, sliding (last_seen_at refreshed at most once a minute on HTTP requests and WebSocket upgrades) |
| Absolute lifetime | 30 days |
| Before the second factor | 15 minutes |
| Revocation | logout; DELETE /auth/devices/:id for a session; password change revokes every other session |
| WebSockets | registered per auth session; closed with 4401 as soon as the session is revoked, and re-checked every 60 s for expiry |
GET /auth/status returns setupRequired, authenticated (true only with a completed second factor),
the user, enrolled methods (only for a caller with a session), the CSRF token and the effective expiry.
GET /auth/devices lists passkeys and active browser sessions (user agent, IP, last use) and marks the
current one.
Throttling and rate limits
Failure throttling (security/throttle.ts, table login_attempts, survives restarts) applies to
password, TOTP, setup-token, passkey verification and password change:
| Key | Limit per 15 minutes |
|---|---|
| username | 5 failures |
| client IP | 10 failures |
| global | 100 failures |
At the limit the key is locked for 60 s, doubling with each further failure, capped at 15 minutes. The
response is 429 with Retry-After and {error:{code:"locked",details:{retryAfterSec}}, ok:false, next:"done", retryAfterSec}.
A success clears the username and IP counters (never the global one). A correct password followed by a
pending second factor does not count as a success, so re-entering the password cannot reset the counter
that protects the 6-digit code.
Request rate limits per minute (app.ts): credential endpoints 20 per client IP (one shared bucket),
anonymous GET /auth/status 240 per IP, GET /health 600 per IP, all other API calls 600 per session.
The client IP is the socket peer unless the peer is listed in CR_TRUSTED_PROXIES, in which case
X-Forwarded-For is honoured.
Password
argon2id through Node's built-in crypto.argon2 when available (64 MiB, 3 passes, parallelism 1),
otherwise scrypt (N = 2^17, r = 8, p = 1); stored as a PHC-style string with its parameters. Hashes are
upgraded at login when the cost parameters changed. POST /auth/password requires the current password
and revokes all other sessions.
Audit
Every authentication event is written to audit_events with actor, client IP and outcome
(auth.setup, auth.login, auth.totp, auth.passkey.login, auth.passkey.register,
auth.totp.enroll.*, auth.totp.delete, auth.device.delete, auth.password, auth.logout).
Secrets never appear in the audit detail or in logs.
Owner first-run steps on production
ssh M5M36 'cat ~/claude-remote-data/setup-token'- Open
https://claude.spboucher.ai/setup/, paste the token, create the administrator (password ≥ 12 characters). - Enrol a passkey and/or TOTP (mandatory).
- Create a project, start a Claude session, run
/logininside it (Claude Code's own authentication). - Copy
~/.config/claude-remote/backup.passfrom the node to a safe place.
Limitations / not verified
- As of 2026-11-10 no administrator account exists on production; the steps above are pending.
- Passkey registration and login were verified against a software authenticator
(
apps/server/src/testkit/soft-authenticator.ts), not in a real browser or on a real device. - There is no "forgot password" or recovery-code flow. Recovery requires direct database access on the node (see backup-recovery.md).
- Idle time is refreshed by HTTP requests and WebSocket upgrades, not by traffic on an already open WebSocket. A terminal left open is closed (4401) once the session's idle or absolute limit passes without any other request from that browser.
- There is a single account; no roles.