- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
20 KiB
Authentication & Session System
Source files:
packages/server/src/routes/auth.ts-- Registration, login, username availability endpointspackages/server/src/routes/users.ts-- Password change, account deletion endpoints (lines 58-156)packages/server/src/routes/admin.ts-- Admin password reset endpoint (lines 232-265)packages/server/src/utils/auth.ts-- Password hashing, JWT sign/verify,authenticatepreHandler,requireAdminpackages/server/src/utils/userDeletion.ts--tombstoneUser()transactional account erasurepackages/server/src/utils/sanitize.ts--sanitizeUser()strips internal fields, anonymizes deleted userspackages/server/src/ws/handler.ts-- WebSocket auth handshake (lines 1282-1374)packages/web/src/stores/authStore.ts-- Client session state, login/register/logout/password/delete actionspackages/web/src/hooks/useAuth.ts-- Route guard hook (redirect to/loginwhen no token)packages/web/src/App.tsx--ProtectedRouteandAuthRedirectroute wrapperspackages/web/src/utils/identity.ts-- Federation-aware identity helpers (parseFederatedUsername,isSelf,canonicalUserMatch)packages/web/src/utils/federationOps.ts-- Cross-instance password sync and account deletion propagationpackages/server/src/config.ts--jwtSecret,jwtExpiresIn,registrationOpenconfig
DB tables: users, instanceSettings. See database.md for full schemas.
1. Password Hashing
Library: bcryptjs
Salt rounds: 12 (constant SALT_ROUNDS in auth.ts)
hashPassword(password: string): Promise<string> -- bcrypt.hash(password, 12)
verifyPassword(password: string, hash: string): Promise<boolean> -- bcrypt.compare
Federation stub marker: Replicated user stubs have passwordHash = '!federation-replicated'. Since bcrypt never produces this value, login is impossible for stubs. See federation.md for identity resolution.
2. JWT Management
Signing
interface JwtPayload {
userId: string;
username: string;
iat?: number; // Auto-set by jsonwebtoken library (seconds since epoch)
}
- Algorithm: HS256 (enforced on verify via
{ algorithms: ['HS256'] }) - Secret:
config.jwtSecret(envJWT_SECRET, minimum 32 characters -- startup crash if shorter) - Expiry:
config.jwtExpiresIn(envJWT_EXPIRES_IN, default'30d') - Library:
jsonwebtoken
signJwt(payload) creates a token with { expiresIn } option. The iat field is auto-injected by the library.
Validation (authenticate preHandler)
Applied as preHandler on all authenticated routes. Flow:
- Extract
Bearer <token>fromAuthorizationheader verifyJwt(token)-- checks signature (HS256) and expiry- DB lookup: fetch
id,isDeleted,passwordChangedAtfromuserstable - Reject if user not found or
isDeleted === 1 - Token revocation check: if
passwordChangedAtis set andpayload.iatexists, reject ifiat < Math.floor(passwordChangedAt / 1000)(JWTiatis seconds,passwordChangedAtis milliseconds) - Attach
userIdandusernametorequestobject
Token Revocation
There is no token blocklist. The only revocation mechanism is the passwordChangedAt timestamp:
- When a user changes their password (or an admin resets it),
passwordChangedAtis set toDate.now() - All tokens issued before that timestamp (
iat < passwordChangedAt/1000) are rejected - A fresh token is issued after password change
Exception: Federation password self-healing (see section 4) does NOT set passwordChangedAt -- it is a state correction, not a password change, so existing valid JWTs remain valid.
WebSocket Auth
ws/handler.ts:registerWebSocket() -- WS connection at /ws:
- Client connects, 10-second auth timeout starts
- First message must be
{ type: 'auth', token: '<jwt>' } verifyJwt(token)validates signature and expiry- DB check: reject if user deleted or token revoked (same
passwordChangedAtlogic as REST) - On success: clears timeout, sets status to
'online', registers connection, sendsreadypayload, broadcasts presence - On failure: sends error message and closes socket
3. Registration Flow
Endpoint: POST /api/auth/register
Rate limit: 10 requests / 2 minutes per IP
Auth: None
Input Validation
| Field | Rules |
|---|---|
username |
Required string. Trimmed, lowercased. |
password |
Required string. Minimum 8 characters. |
displayName |
Optional. Trimmed or null. |
avatarColor |
Optional. Must be in AVATAR_COLORS array, else random. |
homeInstance |
Optional (federation only). Max 253 chars, alphanumeric + . - _. |
homeUserId |
Optional (federation only). Stored if homeInstance is present. |
Username Validation (Two Paths)
Local registration (homeInstance absent):
- Length: 3-32 characters
- Pattern:
/^[a-z0-9_]+$/(lowercase alphanumeric + underscore) - No
@allowed
Federated/replicated registration (homeInstance present):
- MUST use
username@domainformat (plain usernames reserved for native users) - Local part: 3-32 chars,
/^[a-z0-9_]+$/ - Domain part: 1-253 chars,
/^[a-zA-Z0-9._-]+$/ - Total: max 100 characters
Registration Gate
Registration open/closed is determined by:
instanceSettings.registrationOpen(DB, id=1) -- if not null, this takes priorityconfig.registrationOpen(envREGISTRATION_OPEN, defaulttrue) -- fallback
Both are checked. DB value overrides env when explicitly set by admin.
First-User Admin Promotion
const userCount = db.select().from(schema.users).all().length;
const isFirstUser = userCount === 0 && !homeInstance;
The very first user registered on the instance (and only if local, not replicated) gets isAdmin = 1.
Avatar Color Assignment
const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const;
If requestedAvatarColor is provided and is in AVATAR_COLORS, use it. Otherwise, pick randomly from the array.
Registration Steps
- Validate inputs (username format, password length)
- Check registration is open
- Check username uniqueness (exact match on lowercased username)
- Hash password (bcrypt, 12 rounds)
- Generate Snowflake ID
- Insert user row with
status: 'online', admin flag if first user - Sign JWT with
{ userId, username } - Return
{ token, user }(user sanitized viasanitizeUser(user, true))
Username Availability Check
Endpoint: GET /api/auth/check-username?username=<name>
Rate limit: 30 requests / 1 minute per IP
Auth: None
Validates format (same rules as local registration: 3-32 chars, /^[a-z0-9_]+$/), checks registration gate, then queries users table for existence. Returns { available: boolean, reason?: string }.
4. Login Flow
Endpoint: POST /api/auth/login
Rate limit: 15 requests / 2 minutes per IP
Auth: None
Steps
- Validate
usernameandpasswordare present strings - Look up user by
username(trimmed, lowercased) - Reject if not found (generic "Invalid username or password")
- Reject if
isDeleted === 1("This account has been deleted") - Verify password via bcrypt
- If password invalid AND user is federated: attempt self-healing (see below)
- If password invalid AND user is local: reject
- Set user status to
'online' - Sign JWT, return
{ token, user }
Federation Password Self-Healing
When local bcrypt verification fails for a user with homeInstance set:
- Extract base username (strip
@domainif present) - POST to
https://{homeInstance}/api/auth/loginwith base username and provided password - Timeout: 10 seconds (
AbortController) - If home instance accepts (200):
- Re-hash password locally:
hashPassword(password) - Update local
passwordHash-- but do NOT setpasswordChangedAt(this is a state correction, not a password change; setting it would invalidate existing valid JWTs on this instance) - Log the self-healing event
- Continue with login success
- Re-hash password locally:
- If home instance rejects: return "Invalid username or password"
- If home instance unreachable (network error/timeout): return "Invalid username or password" (fall back to local-only rejection)
This flow ensures that when a federated user changes their password on their home instance, they can still log in on remote instances even if the remote's hash is stale.
5. Password Change
User Password Change
Endpoint: POST /api/users/@me/change-password
Rate limit: 5 requests / 15 minutes
Auth: JWT (authenticate preHandler)
Request body: { currentPassword?: string, newPassword: string }
| User type | currentPassword |
Behavior |
|---|---|---|
Local (homeInstance is null) |
Required | Verified via bcrypt against stored hash |
Federated (homeInstance set) |
Not required | JWT auth is sufficient (home instance already verified the change) |
Steps:
- Validate
newPasswordis string, min 8 chars - Load user from DB
- If local: require and verify
currentPassword - Hash new password
- Update
passwordHashANDpasswordChangedAt = Date.now()-- this invalidates all prior tokens - Sign fresh JWT, return
{ token }
Admin Password Reset
Endpoint: POST /api/admin/users/:id/reset-password
Auth: JWT + requireAdmin (instance admin only)
Guards:
- Target user must exist
- Target must not be deleted
- Target must not be federated (
homeInstancemust be null -- "Federated users authenticate via their home instance")
Steps:
- Generate temporary password:
crypto.randomBytes(12).toString('base64url')(16 chars) - Hash it and update
passwordHash+passwordChangedAt = Date.now() connectionManager.forceDisconnectUser(targetId)-- closes all WS connections, forcing re-auth- Return
{ temporaryPassword }-- admin must relay this to the user out-of-band
Cross-Instance Password Propagation (Client-Side)
When a user changes their password on their home instance, authStore.changePassword():
- Changes password on home instance via API
- Updates local token in localStorage and Zustand state
- Calls
changePasswordOnRemotes(newPassword)fromfederationOps.ts
changePasswordOnRemotes() flow:
- Gets all connected remote instances from
instanceStore - Cancels any existing retry timers for those origins
- For each connected instance, calls
inst.api.users.changePassword({ newPassword })with retry:- Initial retry:
retryWithBackoff()-- 3 attempts, exponential backoff starting at 2000ms (2s, 4s, 8s) - On success: updates cached token for that instance, clears pending sync flag
- Initial retry:
- If initial retries fail, starts
scheduleBackgroundRetry():- Schedule: 10 attempts at 30s intervals (5 min), then 12 attempts at 5min intervals (60 min)
- Each attempt looks up current instance from store (avoids stale references)
- Stops if instance disconnected or removed
- On exhaustion: sets
pendingPasswordSyncflag on the instance (UI indicator)
Timer management:
activeRetryTimersmap tracks per-origin retry timersclearPasswordSyncTimers()cancels all active retries (called on logout)- New password change cancels existing retry loops for affected origins
6. Account Deletion
Self-Deletion
Endpoint: DELETE /api/users/@me
Rate limit: 3 requests / 15 minutes
Auth: JWT (authenticate preHandler)
Request body: { password: string, username: string }
Pre-checks:
usernamemust match stored username (confirmation safeguard)- Local users must provide and verify
password; federated users rely on JWT auth - Must not own any spaces (returns 400 with
ownedSpaceslist)
Client-side flow (authStore.deleteAccount()):
- Call
deleteAccountOnRemotes()first (best-effort, see below) - Call
api.users.deleteAccount()on home instance - Clear localStorage token, reset all user-scoped stores
Federation Account Deletion
deleteAccountOnRemotes() runs before home deletion:
- For each connected remote instance, calls
inst.api.users.deleteAccount({ password: '', username: inst.username }) - Password is empty string (not needed for federated users on remotes)
- Best-effort: failures are caught and returned as
FederationOpResult[]but do not block home deletion
Tombstoning (tombstoneUser())
All cleanup runs in a single SQLite transaction:
Relationship cleanup (deletes):
spaceMembers-- removes from all spacesmemberRoles-- removes all role assignmentsfriends-- removes all friendships (both directions)friendRequests-- removes all friend requests (both directions)dmMembers-- removes from all DM channelsreadStates-- removes all read state recordsreactions-- removes all message reactionsdmReactions-- removes all DM reactionsspaceFolders-- removes all space foldersbans-- removes bans where user is target (try/catch for table existence)joinRequests-- removes join requests (try/catch)voiceRestrictions-- removes voice restrictions (try/catch)
Moderator reference cleanup (nullifies):
bans.bannedBy-- nullified where points to deleted uservoiceRestrictions.moderatorId-- nullifiedjoinRequests.decidedBy-- nullified
Channel override cleanup:
- Deletes
channelOverrideswheretargetType = 'member'andtargetId = uid
Group DM ownership transfer:
- For each group DM owned by the user, transfers to next remaining member
- If no remaining members, DM becomes orphaned
Orphaned DM cleanup:
- Finds DM channels with zero members after removal
- For each: collects attachment filenames, deletes attachments, reactions, messages, and the channel
User row anonymization:
username: '!deleted:{uid}' -- frees original username for reuse
passwordHash: crypto.randomBytes(32).toString('hex') -- random, unverifiable
displayName: null
avatar: null
banner: null
bio: null
customStatus: null
accentColor: null
avatarColor: null
replicatedInstances: '[]'
isDeleted: 1
status: 'offline'
isAdmin: 0
Return value: Array of filenames to delete from disk (avatar, banner, orphaned DM attachments). Caller handles disk cleanup.
Post-transaction (in route handler):
- Delete files from disk via
deleteUploadFile() connectionManager.forceDisconnectUser()-- closes all WS connections, leaves voice rooms, broadcasts presence
sanitizeUser() for Deleted Users
When isDeleted === 1, returns an anonymized profile:
username: 'Deleted User'- All profile fields null/empty/false
status: 'offline'- Only
idandcreatedAtpreserved
7. Client-Side Session Lifecycle
State (authStore)
interface AuthState {
token: string | null; // Persisted in localStorage as 'backspace_token'
user: User | null; // Current user object
isLoading: boolean;
error: string | null;
}
Initialization: token is read from localStorage.getItem('backspace_token') on store creation.
initSession(token, user)
Called after successful login or registration:
resetUserStores()-- clears all user-scoped stores (chat, space, social, voice, instance, activity) andclearSelfIds()from identity registry- Saves token to localStorage
- Sets token + user in Zustand state
- Fires
useInstanceStore.autoConnectAll()(fire-and-forget) for federation
loadUser()
Called by useAuth() hook when token exists but user object is null:
- Calls
api.users.me()to fetch current user - On success: sets user, triggers
autoConnectAll() - On failure: removes token from localStorage, clears state (forces redirect to login)
logout()
- Removes token from localStorage
- Calls
resetUserStores()(clears all stores + self IDs) - Sets token and user to null
Route Guards
ProtectedRoute (in App.tsx):
- Reads
tokenfrom authStore - If no token:
<Navigate to="/login" replace /> - Used for
/channels/:spaceId/:channelId?and/explore
AuthRedirect (in App.tsx):
- Reads
tokenfrom authStore - If token present: redirects to
?redirectparam or/channels/@me - Used for
/loginand/registerroutes - Prevents authenticated users from seeing auth pages
useAuth() hook:
- Watches
token,user,isLoading - If no token: navigates to
/login - If token but no user and not loading: calls
loadUser() - Returns
{ user, isLoading, isAuthenticated }
Login Page
- Fields: username, password
- Redirect support: reads
?redirectparam, navigates there on success (validated: must start with/, not//) - Rate limit handling: catches
RateLimitError, shows countdown timer - Links to register page (preserves redirect param)
Registration Page (Two-Step)
Step 1 -- Credentials:
- Fields: username, password, confirm password
- Client-side validation: 3-32 chars,
/^[a-z0-9_]+$/, passwords match, min 6 chars - Debounced username availability check (500ms delay, abort on new input)
- Continue button disabled if username taken or invalid
Step 2 -- Personalization:
- Fields: display name (optional), avatar color picker, avatar upload (with crop modal)
- "Get Started" button: registers with personalization
- "Skip for now" button: registers without personalization
- Registration flow:
- Call
api.auth.register()-- saves token to localStorage but NOT to Zustand (prevents prematureAuthRedirect) - If avatar file selected: upload file, then
api.users.update({ avatar })(failure is non-fatal) initSession(token, finalUser)-- activates Zustand state, triggers redirect- Navigate to redirect param or
/channels/@me
- Call
8. Federation-Aware Identity Utilities
parseFederatedUsername(username)
Splits a potentially federated username:
"youruser@nova.ddns.net" -> { baseName: "youruser", domain: "nova.ddns.net" }
"youruser" -> { baseName: "youruser", domain: null }
Self-ID Registry
Module-level Set<string> tracking all Snowflake IDs belonging to the current user across connected instances:
registerSelfId(id: string) -- adds ID (called from WS ready events)
clearSelfIds() -- clears all (called on logout/session reset)
isSelf(user, homeUser)
Determines if a user object represents the current user. Cascading checks:
- Same
id(same instance, trivial) _knownSelfIds.has(user.id)(cross-instance via registry)user.homeInstance === window.location.hostAND base usernames match
canonicalUserMatch(a, b)
Federation-safe comparison of two user-like objects. Cascading strategies:
- Same
id-- trivial match homeUserIdcross-matching (both have it, or one matches the other'sid)- Username + home instance fallback: parse base names, derive home from
homeInstanceor domain part of username, compare
resolveDisplayIdentity(user, homeUser)
If user is a replicated alias of homeUser (via isSelf), returns homeUser for display purposes. Otherwise returns user unchanged.
9. Rate Limits Summary
| Endpoint | Max | Window |
|---|---|---|
POST /api/auth/register |
10 | 2 min |
GET /api/auth/check-username |
30 | 1 min |
POST /api/auth/login |
15 | 2 min |
POST /api/users/@me/change-password |
5 | 15 min |
DELETE /api/users/@me |
3 | 15 min |
All keyed by request.ip.
10. Configuration Reference
| Config key | Env var | Default | Notes |
|---|---|---|---|
jwtSecret |
JWT_SECRET |
(required) | Min 32 chars, startup crash if shorter |
jwtExpiresIn |
JWT_EXPIRES_IN |
'30d' |
Passed to jsonwebtoken expiresIn option |
registrationOpen |
REGISTRATION_OPEN |
true |
Overridden by instanceSettings.registrationOpen in DB |
11. requireAdmin Guard
auth.ts:requireAdmin() -- used as preHandler alongside authenticate:
- Loads full user from DB by
request.userId - Rejects with 403 if user not found or
isAdmin !== 1 - Used by admin routes (user management, password reset, federation peer management)