Skip to content

Authentication architecture

The administrator’s view of this is Authentication. This is how it is built.

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.

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.

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.

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.

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.

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.

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.

BACKEND_DESIGN.md §6 is normative for everything on this page.