Authentication architecture
The administrator’s view of this is Authentication. This is how it is built.
One session service, three surfaces
Section titled “One session service, three surfaces”The governing decision: there is a single source of truth for sessions, and every authentication surface produces the same session model.
browser native app SSO provider (httpOnly JWT) (bearer + refresh) (OAuth2 / OIDC + PKCE) │ │ │ └───────────────────┼────────────────────────┘ ▼ sessionService createSession(user, authMethod) validateSession()Controllers call createSession; middleware calls validateSession. Nothing invents its
own notion of “logged in”.
That matters more than it sounds. Three front doors with three session implementations is three places for an authorization bug to hide, and the one that gets least attention is the one that gets exploited.
The three surfaces
Section titled “The three surfaces”Web — a JWT signed with JWT_SECRET, carried in an httpOnly, sameSite=Lax cookie.
secure is decided per request (COOKIE_SECURE=auto → secure: req.secure), which is
what lets one deployment work both over HTTPS through a proxy and over plain HTTP on a LAN
address.
Mobile — short-lived bearer access tokens plus rotated, hashed, revocable refresh tokens. Hashed server-side, so a database disclosure does not hand over live sessions.
SSO — Google, Discord or a custom OIDC provider, PKCE-guarded.
SSO is link-only, by policy
Section titled “SSO is link-only, by policy”An external identity must already be linked to an existing account. Identities are never auto-provisioned.
This is a deliberate policy rather than an unimplemented feature. Auto-provisioning turns “anyone with a Google account” into “anyone with an account here”, which is not a decision a site operator should make by installing an OAuth client.
Admin is re-validated every request
Section titled “Admin is re-validated every request”Roles are re-checked against the database on every admin request, not trusted from the token.
The consequence is the point: a demoted user loses access at once, rather than when their token happens to expire. A stateless JWT that carried the role would keep asserting it for up to a day.
Trusted devices gate the second factor only
Section titled “Trusted devices gate the second factor only”A second, separate httpOnly cookie (rg_trust, 30 days by default) lets a browser or app
skip the TOTP step on future logins — never the password.
Four properties, each chosen:
- It is opaque and sha256-hashed server-side, stored in a table. It is not a JWT claim, so the stateless session token is unchanged.
- It is per-row revocable, from the admin panel or by the user.
- It deliberately outlives logout. Logging out ends a session; it does not make the device untrusted, because the device is still the same device.
- It is cleared on untrust, password change, password reset, or disabling TOTP.
Recovery codes (bcrypt, single-use) are the lockout fallback. Every trusted-device and MFA action is audit-logged.
The login-hardening layer
Section titled “The login-hardening layer”Bot scoring with automatic IP banning, TOTP 2FA, a honeypot field, and rate limiting with backoff. The admin Bot Activity panel is deliberately read plus emergency-unban only — it is a window onto an automatic system, not a control surface for it.
Where core’s boundaries stop
Section titled “Where core’s boundaries stop”Core’s security boundaries end at authentication, roles and the session.
A module that serves game data brings its own audience rules, and core does not police
them beyond the gates it hands over — requireAuth, requireRole, and the tier group
gates. See The module API.
module-uo’s is the worked example, and it is a real boundary rather than a convenience
filter: an admin-configurable, per-feature and per-field audience ladder with fail-closed
defaults, applied at routes, at SSE subscribe time, and at the navigation. All three,
because a surface that is filtered in only two of those places leaks through the third.
Content Security Policy
Section titled “Content Security Policy”script-src 'self' with no inline script, which is why module chunks are served
same-origin and why an import map was never an option.
form-action 'self' is pinned explicitly rather than inherited, because it blocks an
injected form POSTing credentials off-origin — an exfiltration path connect-src does not
cover.
Violation reports go to a same-origin sink that stores nothing: reports describe attacks
against this site and are not handed to a third-party collector. It parses both wire formats
(browsers disagree), and always answers 204 even for malformed input — a 4xx would make
the error handler log attacker-supplied bodies and turn an open endpoint into a log-flood
primitive.
Canonical document
Section titled “Canonical document”BACKEND_DESIGN.md
§6 is normative for everything on this page.