A browser that never holds the API token.
1. What the browser used to carry
Until v1.123, signing in to the loomcycle Web UI meant pasting an API token into a box. The UI put that token in a cookie and sent it back on every request. It set the cookie without checking the token first, and it did the same for a token that arrived in the URL as ?token=. Signing out cleared the browser's copy and nothing else.
So the browser held the API credential itself. Anything that could read the cookie jar could read a bearer that worked on every transport, not just the UI. Nothing on the server could revoke "this browser's session", because there was no session: there was only the token. Cross-site request forgery rested on SameSite alone. And there was no way in for a person who had no token to paste.
That was fine for one operator on a laptop. It is not fine for the thing we are building now: a document-based reference system where many people sign in with their organisation's identity provider, read what their groups allow, and never see an API token at all.
The browser should hold proof that someone signed in. It should never hold the key to the API.
v1.123 replaces the pasted bearer with server-side sessions and adds three sign-in gates beside the paste box: a password gate, an OIDC gate and an OAuth gate.
2. Sessions the server owns
A sign-in now creates a session on the server. The browser receives an opaque, HttpOnly cookie. The server stores only the SHA-256 of the session id, so a dump of the session table opens nothing.
A session made from a pasted token does not freeze that token's authority at the moment of sign-in. It re-checks the token on every request. Retire the token, change a principal's secret or change LOOMCYCLE_AUTH_TOKEN, and the session ends. The session always carries the token's current scopes. A changed token pepper ends every session, and so does a different User-Agent.
Lifetimes sit under auth.session: idle (default 8 hours), absolute (default 7 days) and reauth_window (default 15 minutes), the window inside which a sensitive change counts as a recent sign-in. Ended sessions are deleted every hour. Sessions are never snapshotted, so a restored deployment starts with nobody signed in.
The cookie rules are strict on purpose:
- Over HTTPS the cookie is
__Host-lc_session, which a browser binds to the exact host and path. - Behind a TLS terminator, a
https://LOOMCYCLE_PUBLIC_URLwith a matchingHost, or a loopback peer, counts as HTTPS. No forwarded header is trusted to say so. - Over plain HTTP the cookie is
lc_session, with a warning in the log.
CSRF no longer rests on SameSite. A write authenticated by the cookie must carry X-Loomcycle-UI: 1 and come from the server's own origin. A WebSocket upgrade needs the same origin, and so does the login route. Requests that carry an Authorization header are untouched, so scripts and clients keep working as before.
Sign-out is real now. POST /v1/_auth/logout revokes the session on the server, clears the cookies and sends Clear-Site-Data. Logins, failed logins, logouts, expiries and revocations are audited (auth_login, auth_login_failed, auth_logout, auth_session_expired, auth_session_revoked), never with a credential or a session id in the record.
GET /v1/_me gains an auth block: the mode, and for a session the gate, the authentication methods (amr), the sign-in time and when the session expires.
3. Strict mode
auth.mode (or LOOMCYCLE_AUTH_MODE) has two values.
token, the default, keeps the paste box. Existing deployments sign in as they did; the only difference is that a pasted token now becomes a session instead of a cookie that is the token.
strict offers only the gates an operator lists. It turns off the login handoff, the old bearer cookie and the bearer carried in the WebSocket subprotocol. It needs HTTPS for the cookie unless an operator sets LOOMCYCLE_UI_INSECURE_COOKIES=1 on purpose.
Strict mode is also hard to weaken by accident. Strict mode with no interactive gate fails the config load. Strict mode with no credential refuses to boot. A config reload cannot lower the mode, and a fleet default set in the environment cannot be lowered by a config file.
4. The password gate
kind: password is for deployments that keep their own users, such as a website that signs up its own customers. loomcycle keeps the password, and a person types it only on loomcycle's own pages: /ui/login, /ui/invite, /ui/reset and /ui/account. No management route accepts a password.
- Storage: Argon2id, peppered with a key derived from
LOOMCYCLE_SECRET_KEY. During a key rotation,LOOMCYCLE_SECRET_KEY_PREVIOUSkeeps verifying old hashes. The gate needsLOOMCYCLE_SECRET_KEYandLOOMCYCLE_PUBLIC_URL, or the config fails to load. - Policy, after NIST SP 800-63B-4: 15 characters when the password is the only factor, 8 with an authenticator, and no composition rules. The 10,000 most common passwords are refused, and so is anything in an operator's
denylist_file. - Second factor: TOTP with ten single-use recovery codes. A password reset never clears the authenticator.
- Logins are unique per gate across tenants, so the form never asks which tenant. A new user's subject is
<gate>:<random>, not the login name. - Throttling runs per account (backoff, then lockout), per client address, and per address and login together. It lives in the store, so every replica sees the same counters.
auth.trusted_proxiesandauth.client_ip_headersay which proxies may name the client. - Invite and reset links carry their code in the URL fragment (
/ui/invite#…,/ui/reset#…). A browser never sends the fragment to a server, so the code never lands in an access log.
A website that manages its own users
Management lives under /v1/_authgate/{gate}/: invites, resets, credential removal, login rename, identities and tenant status. A website reaches it with a token carrying the new auth:manage scope.
auth:manage is deliberately narrow. It must be the token's only scope. It reaches those routes and the /v1/_users list, create, update and delete, and nothing else. It is refused over gRPC. It may bootstrap a tenant's owner once, only while the tenant has never had a user, and that owner is bound to builtin:tenant-operator. Every change to a user above the gate's provisioning floor is refused, unless the operator sets manager_may_recover_owners, which lifts the limit for owner recovery only.
This is how loomcycle.cloud now signs people up. Its landing page hands out invite and reset links through an auth:manage token, where it used to mint tenant tokens with the admin token.
5. The OIDC gate
kind: oidc is for organisations that already have an identity provider. It ships presets for google (which requires allowed_domains), entra, cognito, okta, keycloak, identity-center and auth0, plus generic for anything that speaks OpenID Connect.
loomcycle acts as a confidential client. It redeems the authorization code and verifies the ID token itself, so the provider's tokens never reach the browser. The flow uses PKCE with S256, state and nonce. Only asymmetric signatures are accepted, and the provider's keys are re-fetched at most once a minute. The redirect URI is <LOOMCYCLE_PUBLIC_URL>/v1/_auth/complete/<gate>, and the client secret comes only from the environment variable client_secret_env names.
Three decisions in the identity core matter more than the protocol details.
The subject is never the e-mail. It is always <gate>:<sub>. E-mail addresses get reassigned when people leave; the provider's subject identifier does not. identity_map places a person in one tenant, or maps a claim through allowed_tenants.
New people can wait for approval. provision is never, known (the default) or auto, at the level set in auth.lifecycle.new_user. With new_user.status: pending, a newcomer reaches an "awaiting approval" page and nothing else, until an operator approves them in the Users console.
Linking is explicit. A person links another gate's account from their own session, through a fresh sign-in at the provider. loomcycle never links two accounts because their e-mails match.
6. Who gets to say which groups you are in?
This is where the gates meet the access control that shipped in v1.122: rule lists, nested groups and bindings, with an explicit deny over any allow. A sign-in decides who you are. Your groups decide what you may do. So the question that matters is who is allowed to put you in a group.
The answer is narrow. Only a gate marked trusted may grant more than the provisioning floor or sync groups from the provider. Group sync adds and removes members only in the gate's own groups, the ones named <gate>:…. A provider can say "this person is in entra:policy-readers". It cannot add anyone to a group an operator wrote by hand, and it cannot remove them from one.
An identity provider can describe a person. It cannot rewrite the rules an operator wrote.
That split lets an operator bind a rule list to entra:policy-readers ("may read document:tenant:/policies/**, may run chat/*, never gets Bash") and let the provider keep the membership current, while every deny the operator wrote by hand stays where it is. Synced changes are audited as auth_groups_synced.
7. The OAuth gate
kind: oauth covers providers that issue no ID token: github (including GitHub Enterprise through base_url), gitlab, gitea and generic.
- The subject is
<gate>:<numeric user id>, never the login name, which its owner can rename. Anidentity_mapthat nameslogin,nameorusernamefails to load. - An e-mail is used only when the provider marks it verified and
trust_emailis on. It defaults on for github.com and gitlab.com and off for self-managed instances andgeneric. - Groups come only from
allowed_orgs, andrequire_orgrefuses anyone outside them. - A
githuborgitlabgate on the public instance loads only withallowed_orgsandrequire_org: true, or with an explicitallow_any_account: true. Abase_urlthat names the public host counts as the public instance, so it cannot be used to slip past the rule. - An OAuth session cannot link or unlink accounts. An OAuth provider cannot prove a recent sign-in, so linking happens from a password or OIDC session.
8. What changes on upgrade
- In
tokenmode, a browser that was already signed in keeps working: the old bearer cookie is read for this release only, and never set again. Signing in once moves it to a session. /ui?token=…no longer signs anyone in. It redirects to the login page.- A Web UI bundle built for an earlier server sends no
X-Loomcycle-UIheader, so the server refuses it in favour of the built-in UI. - Behind a TLS terminator, set
LOOMCYCLE_PUBLIC_URL=https://<public host>so the cookie becomes__Host-lc_session. - Snapshots carry no sign-in material: sessions, logins, passwords, authenticators, recovery codes, invites, resets and linked identities stay behind. Users travel; a restored user has no login until one is issued on the target.
The full reference is "Web UI sign-in" and the three gate sections in docs/CONFIGURATION.md.
The pasted token was never a design. It was the shortest path to a working UI for one person, and it held up as long as that person was the only one signing in.
A reference system that many people use needs the opposite shape. The browser holds a session the server can end. The person proves who they are to their own organisation's provider, or to a password form that only loomcycle serves. Their groups decide what they may read, and an operator's rules decide what those groups mean. The API key stays where it belongs: on the server, and in the hands of the scripts that were given one.