Every daemon needs to know who is talking to it. auth turns a browser login — GitHub, Google, Discord, Telegram, or a local password — into one canonical user id, and carries that id across the daemon graph as a signed HTTP header.
arizuko is a bunch of small Go daemons (gated, webd, dashd, proxyd, more) that talk over HTTP. None of them owns the login session by itself. When dashd serves your operator page or gated decides whether to deliver a message, it needs an answer to one question: who is this request from? If the answer were "look at the cookie yourself," every daemon would re-implement login. Instead, exactly one daemon (proxyd) reads the cookie, looks up the user, and writes the answer into headers the others trust.
You log in with any provider. Each provider gives back a string the auth code calls a sub — short for "subject," but think "this user's id with this provider." subs always carry their provider as a prefix:
github:48291744
google:114019583...
discord:837412...
telegram:user/5511234
local:alice
One human usually has several. You link them at /dash/profile: pick one provider as your canonical sub, click "Link…" on another, finish the OAuth dance, and arizuko writes auth_users.linked_to_sub = <canonical> for the new row. From then on, any linked sub resolves to the same canonical sub on login. Provider details:
GITHUB_ALLOWED_ORG members-only check.GOOGLE_ALLOWED_EMAILS glob, only honoured when Google asserts email_verified.auth_date window.After login, the browser holds two things:
localStorage. Carries {sub, name, provider, exp}. Sent as Authorization: Bearer … on API calls.HttpOnly; SameSite=Strict; Secure; Path=/auth cookie with a 30-day TTL. Each refresh rotates the cookie single-use — a stolen one is good for one swap, then dead.The canonical sub is decided exactly once: auth.issueSession calls store.CanonicalSub(sub) right before minting the JWT. Whatever provider you logged in with, the minted token carries your canonical sub. Downstream daemons never re-resolve — the sub they see is the answer. Link chains are not allowed; LinkSubToCanonical rejects them, so the lookup is always one hop.
Suppose you click a button in the operator dashboard. The browser sends an HTTPS request to https://your-instance/dash/groups. Trace it:
proxyd on port 443. It looks for the Authorization: Bearer header (or, on web pages, the refresh-token cookie).proxyd.tryAuth verifies the JWT signature with AUTH_SECRET. If valid, it extracts sub, name, and the user's groups.proxyd.setUserHeaders clones the request and stamps four headers: X-User-Sub, X-User-Name, X-User-Groups (JSON), and X-User-Sig — an HMAC of the first three using CHANNEL_SECRET.dashd over the docker network.dashd wraps its handlers in auth.RequireSigned(CHANNEL_SECRET) from auth/middleware.go. The middleware recomputes the HMAC and compares to X-User-Sig. Mismatch → strip all four headers, redirect to /auth/login.r.Header.Get("X-User-Sub") and trusts it. That sub is what auth.Authorize uses to decide what the user may do.AUTH_SECRET signs the JWT the browser holds. CHANNEL_SECRET signs the header proxyd stamps for backends. They are different boundaries: browser ↔ proxyd vs proxyd ↔ backend.You are logged in as google:114alice and you click "Link GitHub." The callback gets a fresh sub github:48291744. Three cases the code handles cleanly:
linked_to_sub = google:114alice, keep your session. Done.No silent ghost-account creation, no ambiguous merges. The user always picks.
For the full state machine, OAuth-state cookie layout, and DB schema, read specs/1/f-auth-oauth.md. For what your canonical sub is allowed to do once it lands in a backend, see grants.