The web app2 of 12
Signing in
On this page
Kotoba ships with one optional password. Set it and the whole app sits behind a login screen; leave it unset and there is no login screen at all.
Screenshot placeholder:
/login— the bobbing chibi with a speech bubble above a cream card titled "Kotoba — private", one password field with a reveal control, and a "Let me in" button.
When the login appears
Only when a password is configured. The gate reads two environment variables in this order and treats
a non-empty value as "on" (lib/gate.ts:48-55):
KOTOBA_WEB_PASSWORD # preferred
KOTOBA_GATE_PASSWORD # accepted alias
With neither set, every request passes through untouched (proxy.ts:17). That is the local-development
default, and it is deliberate: there is no lockout to lock yourself out of.
The order matters and is copied from the backend on purpose. The same value gates both sides, so a
login cannot succeed with one variable while every /api/* call refuses the other (lib/gate.ts:47-50).
Three routes are protected (proxy.ts:46):
/app/:path* the companion
/setup/:path* first run — it writes keys and her name
/login so somebody already signed in is not shown the gate again
/ is deliberately not matched. It renders nothing; it only redirects to /app, where the gate runs
(app/page.tsx:11-13, proxy.ts:40-44).
What happens when you sign in
You type the password and the browser posts it to /gate. The password itself never ships in the
page bundle — only the attempt travels, and it is compared server-side.
The comparison HMACs both the attempt and the real password and compares the digests in constant time
(lib/gate.ts:61-67, 36-41). A wrong password waits 600 ms before answering 401
(app/gate/route.node.ts:36-39). That delay is constant friction against guessing, not a lockout —
there is no attempt counter.
A correct password sets one cookie:
| Property | Value |
|---|---|
| Name | kotoba_gate |
| Contents | an HMAC-SHA256 signature over an issue timestamp |
httpOnly | yes |
sameSite | lax |
path | / |
| Lifetime | 12 hours |
(lib/gate.ts:12-13, 88-98, 100-113.)
The secure flag follows the connection, not the build mode (lib/gate.ts:79-86). Any proxy hop
claiming https turns it on; only a URL that positively says http: turns it off. This matters for
self-hosting: npm start on http://<lan-ip>:3000 is a production build, so keying secure off the
build mode would mark the cookie secure, the browser would silently drop it on an insecure origin, and
the login would loop with no error to read.
The signing key is KOTOBA_GATE_SECRET if you set one, otherwise the password itself
(lib/gate.ts:43-45). Changing the password therefore invalidates every existing session.
After a success the page does a full navigation rather than a client-side one, because the App
Router has cached the earlier redirect to /login and a soft navigation would replay it
(components/LoginGate.tsx:104-107).
Where it sends you afterwards
/login?next=… is attacker-controlled, so the value is resolved against the origin it must not leave
and kept only if it stayed there; anything else falls back to /app (lib/safe-next.ts:14-23). The
obvious test — "starts with a slash but not two" — does not hold, because the URL parser folds /\
into // and strips raw tab, newline and carriage return before parsing. Only the parser can see
that, so the parser is what decides.
The token behind the cookie
When a password is set, the backend requires it on /api/* as well. The browser gets it from
GET /gate/token, which returns the token only to a request carrying a valid signed cookie and
marks the response no-store, private (app/gate/token/route.node.ts). The value is kept in
sessionStorage and sent as a Bearer header (lib/api.ts:10-42).
Where a header cannot be set — an EventSource, an <img>, a download, a window.open — a ?token=
query parameter is used instead (lib/api.ts:45-51). For file viewing the backend swaps that token for
a short-lived cookie and redirects to the same URL without it, so the address bar and browser history
never keep it (api/src/kotoba/server.py:970-978).
With no password configured, /gate/token returns an empty token, gateIsOn() is false, and no
Authorization header is sent — because the backend gate is open too.
Signing out
Settings → Security → Sign out. The row appears only when a password is configured, so there is
never a control that would end nothing (components/panels/SettingsPanel.tsx:682-707).
Signing out drops the local token, deletes the sessionStorage entry, clears the login-diagnosis mark,
asks /gate to expire the cookie with DELETE, and then does a full navigation to /login
(lib/api.ts:56-67, SettingsPanel.tsx:690-694). The full navigation is the point: the in-memory app
state — session id, token, open panels, a live call — must not survive into the next person's session.
The panel states what a sign-out costs: it ends the 12-hour session on this device, and her memory and
files are untouched (SettingsPanel.tsx:699-701).
When the password is right and you land back on the login screen
That is a browser that did not keep the cookie: a secure cookie on a plain-HTTP origin, cookies
blocked for the site, or a proxy that ate Set-Cookie. With nothing else to go on it looks exactly
like a wrong password.
So the login screen records the instant a password was accepted and checks that mark when it mounts.
Landing back within 60 seconds of a success raises a yellow notice reading "The word was right",
pointing at cookies and at the self-hosting notes on plain HTTP (lib/gate-diagnostics.ts:14-51,
components/LoginGate.tsx:79, 106, 322, 342-362). The mark is read once and cleared, so an ordinary later
visit says nothing, and signing out clears it too — otherwise a sign-out would read as a browser that
eats cookies.
This is a shared-password gate for a self-hosted instance. It is not a user-account system: there are no accounts, no roles, and no per-user data. Everyone who knows the password is the same person as far as Kotoba is concerned.
