DocsSign-in and safety
Authentication
On this page
Providers prove who someone is. The core decides what they may do. Nothing is open by default: until a first
admin exists, every route except /setup answers 401.
Roles#
viewer < operator < admin. A role is computed from the auth.users rules on every request, so removing a
rule removes access on the next click. The one exception is deliberate: the admin made by first-run setup, and an
admin made by recovery (below), is stored on the hub, so it works with no rule. Removing a rule does not touch that
account; remove the person instead (Config, Users and roles, Remove; or yarrix-hub auth user remove <user>).
The effective role is the highest of the stored role, the matching rules and default_role.
auth:
providers:
- { type: supabase, url: https://<project>.supabase.co, anon_key: "${env:SUPABASE_ANON_KEY}" }
- { type: tailscale, mode: whois }
users:
- { match: ann@example.com, role: admin }
- { match: bob@example.com, role: operator, terminal: [atlas] } # terminal: host ids or "*"
default_role: none # signed in but unlisted: denied (or "viewer")
match is compared case-insensitively against the verified email (Supabase) or tailnet login (Tailscale).
An admin needs no terminal grant; a viewer can never have one. A terminal needs a browser session no older
than 12 hours, so a long-lived session has to sign in again first.
Providers#
- supabase: OAuth PKCE. The hub verifies the access token (project JWKS, or
jwt_secretfor HS256), then asks Supabase itself (/auth/v1/user) whether the email is confirmed. Nothing in the token'suser_metadatais trusted. The Supabase tokens never reach the browser. - tailscale,
mode: whois: asks the localtailscaledwho owns the TCP peer.socket:overrides the LocalAPI socket path. Tagged devices are refused. - tailscale,
mode: headers: trustsTailscale-User-Loginonly fromtrusted_proxies(default loopback, fortailscale serve). The hub refuses to start on a non-loopback listener unlessallow_remote_proxy: true. - local (password): a username and password kept on the hub, on by default. The password is checked with OPAQUE
(RFC 9807), so the hub never receives it; see Local passwords. A provider is one
AuthProviderobject inapps/hub/src/auth/providers/; see the note intypes.ts.
There are four ways in: Supabase, Tailscale identity, a local password, and the recovery login link.
Setting up Supabase#
Supabase here is only an identity service; Yarrix keeps everything else on your own host. A free project works.
-
Create a project at supabase.com (or run your own Supabase). Note the project URL and the
anonpublic key (Project Settings, API). -
Authentication, Providers: enable the sign-in method you want. Yarrix sends people to
githubunless you setoauth_providerto another one Supabase offers (for examplegoogle). -
Authentication, URL Configuration: add
https://<your-yarrix-host>/api/v1/auth/supabase/callbackto the allowed redirect URLs. The host must be the address people open (see Plain HTTP and HTTPS). -
In
yarrix.yaml:auth: providers: - type: supabase url: https://abcdefgh.supabase.co anon_key: ${env:SUPABASE_ANON_KEY} oauth_provider: github users: - { match: ann@example.com, role: admin } default_role: noneLeave out
jwt_secretto verify tokens against the project's published keys (JWKS). Set it only if your project signs with a shared HS256 secret. -
Set
YARRIX_PUBLIC_URLto the address people open. A person who signs in with an email that noauth.usersrule matches is denied whiledefault_roleisnone.
Only emails that Supabase reports as confirmed are accepted.
Setting up Tailscale#
Nothing to create on the Tailscale side except the machine itself being on your tailnet.
mode: whois(the hub runs on a machine withtailscaled, and people reach it by its tailnet address):auth.providers: [{ type: tailscale, mode: whois }]. The hub asks the local daemon who owns each connection, so there is nothing to log in to. Setsocket:if tailscaled's socket is not in the platform default place (a container needs the socket mounted in).mode: headers(the hub sits behindtailscale serveon the same machine): the proxy setsTailscale-User-Login, and the hub trusts it only fromtrusted_proxies(loopback by default). Keep the hub's own listener on loopback, or the hub refuses to start.
Then grant by tailnet login: auth.users: [{ match: ann@example.com, role: admin }]. Tagged devices are refused.
Because tailscale serve terminates HTTPS, its session cookie is Secure (see Plain HTTP and HTTPS).
Local passwords#
First run. While no admin exists, whoever opens the hub (or /setup) first chooses a username and password and becomes the
admin. There is no PIN and no host name to know. The window lasts one hour from when the hub starts (CLAIM_WINDOW_S), restarting
the hub opens it again, and yarrix-hub auth setup-pin opens a PIN-gated claim at any time. Anyone who can reach the hub can
claim it during that window, so claim it right away. The hub prints "Setup is open to the first visitor until HH:MM" at start,
and /connect and the setup page say so too. The admin's role is stored in the hub's database, so it does not depend on an
auth.users rule. Setup and recovery are refused for a Host that is not an IP, localhost, a single-label name, *.local,
*.ts.net or the YARRIX_PUBLIC_URL host (DNS rebinding), with a 421.
What OPAQUE protects, and what it does not. The browser stretches the password (the memory-hard step runs there) and sends only
blinded values, a proof, or a registration record. The hub never sees the password, and a stolen database yields records that
still need an offline guess per password. It does not encrypt the connection: over plain HTTP anyone on the network path can read
or change what is sent, including the sign-in page itself, so a hostile network can serve a page that captures the password. Use
HTTPS. Treat a copy of the database like a copy of password hashes: the records and the server secret (auth_meta) are in it.
Wrong passwords. A wrong password is detected in the browser, so the hub counts every sign-in start as one failed attempt; a finished sign-in clears the count. Five from one address on one username lock that pair for 15 minutes (the lock survives a restart). 100 in a row switch the password off (423) until recovery. Starts are counted, not finishes, on purpose: each start is one OPRF evaluation, and a client can test a password guess against the response without ever calling finish, so a start is a guess. The trade is that anyone who can reach the hub can burn the cap and force the owner to run recovery. That costs the owner a PIN; counting finishes would cost a guesser nothing.
Recovery. On the hub's machine, yarrix-hub auth setup-pin (after setup) prints a recovery PIN: single use, 15 minutes, it
signs out every session and sets a new password for an existing username (or makes a new admin for an unknown one). Enter it on
/recover. It also clears that account's lockout. The new admin is intended: recovery is break-glass, for an owner who is locked
out and may have no rule to fall back on. That admin is stored (not governed by a rule), appears in Users and roles marked "Stored
by setup or recovery", and is removed like anyone else. One exception keeps the role rules in charge: if an auth.users rule
already matches the new username, the account gets that rule's role and nothing is stored, so deleting the rule ends its access. A lost server secret fails closed (sign-in returns 503) until recovery.
Changing a password. /account asks for the current password (a two-minute single-use ticket), stores the new record, and signs
other sessions out. An account that has no password yet (made before this feature) can set one without a current password only
from a session opened in the last 10 minutes; an older session gets reauth_required and must sign in again or use recovery.
Removing a person. Config, Users and roles has a Remove button on each row (admin browser session; never your own row), and the
hub's own shell has yarrix-hub auth user list and yarrix-hub auth user remove <username-or-id>. Removing ends their sessions on
their next request, deletes their local password and their sign-in links, and writes a user_removed audit row. If a rule still
matches them they can sign in again as a new account, so delete the rule too (the answer says when that is the case). The last
admin cannot be removed: make another first with auth setup-pin.
Later, an admin who cannot reach a provider can mint a 15 minute, single-use link on the host: yarrix-hub auth login-link <user>.
Routes#
The setup page is GET /setup (a browser page, outside the API). Claiming is two calls, POST /api/v1/setup/start (a blinded message) and
POST /api/v1/setup ({username, name, record}, plus pin when a PIN is needed); recovery is /api/v1/recover/start and /api/v1/recover; sign-in is
/api/v1/auth/local/start and /finish; the account page uses /api/v1/account/password[...]. Everything the pages call sits
under /api/v1: GET /api/v1/auth/providers, GET /api/v1/auth/session,
POST /api/v1/auth/logout, GET /api/v1/auth/link?token=... (login link), and per provider
/api/v1/auth/<kind>/start and /callback. Register https://<your-host>/api/v1/auth/supabase/callback as the
redirect URL in Supabase. Under a YARRIX_BASE_PATH these all gain that prefix.
Plain HTTP and HTTPS#
Sign-in works over plain HTTP, so a hub on your own network works without Tailscale or a certificate (for example
http://192.168.1.20:PORT). The session cookie is marked Secure only when the browser reaches the hub over HTTPS:
the request arrived over TLS, a proxy sends X-Forwarded-Proto: https, or YARRIX_PUBLIC_URL is an https://
address. Otherwise the cookie is not Secure, and the browser keeps it. A TLS proxy in front of the hub must send
X-Forwarded-Proto (or YARRIX_PUBLIC_URL must be set to the https address) for the cookie to stay Secure.
Plain HTTP is not encrypted. Anyone on the network path can read or change what is sent, including the sign-in page, so a
password, the session cookie and the login links are all exposed to them. The web UI
shows a "Not encrypted" banner on every page opened over plain HTTP from another machine (localhost is exempt). For
anything beyond your own network, put Yarrix behind HTTPS (a reverse proxy, or tailscale serve) and set
YARRIX_PUBLIC_URL to the https address.
The browser terminal still needs HTTPS or localhost, and refuses plain HTTP even on the local network. Password-based
SSH install works over plain HTTP: the page seals the password in the browser before it is sent, and the hub opens it
once. Someone who can change the page in transit can still capture the password, so use HTTPS for that. See
Passwords on a plain-http hub.
First-run setup works from the network too. With no YARRIX_PUBLIC_URL and a listen address of 0.0.0.0
(or ::), the hub prints a setup address for localhost and one per LAN address. With a plain-HTTP
YARRIX_PUBLIC_URL, it prints that link and a startup warning that the connection is not encrypted.
Sessions and tokens#
- Session cookie
yx_session_<hub id>(the first 8 characters of the hub's id; older hubs used plainyx_session, which is no longer read, so a browser signed in before the upgrade signs in once more):HttpOnly; SameSite=Lax, plusSecurewhen the browser reached the hub over HTTPS (see Plain HTTP and HTTPS). Stored hashed, 12 h idle, 7 d absolute. - Cookies ignore the port. Two hubs on the same hostname with different ports (for example
localhost:5872andlocalhost:7797) share one cookie jar, but each hub names its session cookie after its own id, so signing in to one does not sign you out of the other. - Mutating requests (and WebSocket upgrades) with a foreign
OriginorSec-Fetch-Site: cross-siteare refused. SetYARRIX_PUBLIC_URLwhen a proxy changes the host. - API tokens (
yx_<id>_<secret>, stored as sha256) carry scopes:read(viewer reads; a read-only key),operate(a read-write key for an AI agent, see mcp-and-cli.md),report(backup run reports, optionally limited to named backups) andadmin. An admin makes a key in Settings and picks read-only or read-write. Managing tokens, enrollments and the SSH probe need an admin browser session, not a token. - An
admintoken can edit config files, but not the parts ofyarrix.yamlthat decide who gets in: the sign-in providers, theauth.usersrules (match,role,terminal),default_role, andterminal.enabled/terminal.record. Like commands and SSH targets, those change only from an admin browser session or on disk (403 exec_field_requires_admin_sessionotherwise), so a token cannot raise anyone's access, its owner's included. - The config API shows the role rules and provider types as written. A provider's
url,anon_keyandjwt_secretare masked. yarrix-hub auth token create|list|revokedoes the same from the host.- The API screen (
#api) lists, creates and revokes tokens for admins. A new token's secret is shown once, in the page only; it is never logged or put in a URL, and the hub keeps only its hash. The same screen lists every endpoint from/api/v1/openapi.jsonwith a runnable curl ($YARRIX_TOKENfrom the environment), a live sample fetched with your session, the MCP tools and CLI examples. Its base URL isYARRIX_PUBLIC_URLwhen set (the OpenAPIserversentry carries it), otherwise the page's own address. - Failed guesses (setup PIN, agent PIN, login link, API token) lock the (address, name) pair for 15 minutes; one client cannot lock others out.
- The six-digit PINs have only a million values, so they get more. Their lockouts and counts are kept in the database, so
restarting the hub does not reset them. Across all addresses, 30 wrong guesses in 15 minutes burn the live PINs (every unused
agent PIN expires; the setup PIN is replaced and the new one printed). Setup has a total cap of 100 wrong PINs over every PIN and
every restart; past it the hub makes no new setup PIN and the log says to run
yarrix-hub auth setup-pinon the hub's machine, which prints a new one and resets the count. - Every start of the agent PIN exchange counts toward the 30-in-15-minutes budget, including a start that proves the right PIN.
An unauthenticated client on the LAN can therefore spend the budget and make the live agent PINs expire, which denies
enrollment until an admin mints a new PIN from the Installer tab. That is accepted (decision D-27 in
docs/DECISIONS.md): guessing resistance wins over enrollment availability during an attack.
Backup report tokens are now ordinary report-scoped yx_ tokens, re-minted from the backup's drawer (Report token) or with yarrix-hub auth token create --scope report --backup <id>. The
older yxb_ tokens no longer work.