Authentication
JWT bearer tokens with a per-user version counter for revocation. The mobile app adds refresh tokens: a short-lived access token renewed silently, so a device stays signed in for as long as it's used. The admin panel, the web app and app builds from before refresh tokens keep a 7-day token.
The flow
Tokens
apps/api/src/lib/jwt.ts issues two kinds of access token:
| Refreshable (mobile app) | Legacy (admin, web, older app builds) | |
|---|---|---|
| Issued by | generateAccessToken(userId, tokenVersion, sid) | generateToken(userId, tokenVersion) |
| Lifetime | JWT_EXPIRATION_MINUTES (default 60) | 7 days |
| Claims | userId, tokenVersion, sid, iat, exp | userId, tokenVersion, iat, exp |
| Renewal | POST /token/refresh | Sign in again |
A client opts in with refreshable: true on POST /authenticate or POST /create-account. Without it the response is exactly what it always was, including the web token cookie. Both kinds are HS256 with JWT_SECRET, shared between api and esu. The payload stays minimal: nothing about roles or profile is baked in, and every request loads the user fresh.
Refresh tokens
apps/api/src/services/authSessions.ts. A refresh token is 32 random bytes (base64url), opaque, and belongs to one AuthSession (one row per device sign-in). Only its SHA-256 is stored.
- Sliding expiry.
REFRESH_TOKEN_TTL_DAYS(default 90), pushed forward on every refresh. A device used at least that often never has to sign in again. - Rotation. Every refresh issues a new refresh token. The previous one stays valid for 2 minutes (
previousTokenHash,rotatedAt), so a refresh whose response was lost on a bad network doesn't sign the device out. - Reuse detection. Presenting a rotated-out token after that window means it was copied: the session is revoked, for its legitimate holder too, and a warning is logged.
- Concurrency. The session row is locked (
SELECT … FOR UPDATE) while it rotates. The app also sends one refresh at a time (refreshSessionis single-flight). - Failure. Any reason (unknown, expired, revoked, user not active) answers
401 REFRESH_INVALID, which the app treats as signed out.
| Endpoint | Auth | Purpose |
|---|---|---|
POST /token/refresh { refreshToken } | none (rate limited, 300/IP/15 min) | New access + refresh token |
POST /token/upgrade | legacy bearer token | One-time swap of a pre-refresh install's 7-day token for a session; 409 ALREADY_REFRESHABLE for a token with sid |
POST /logout { refreshToken? } | none | Ends that refresh session (and clears the web cookie) |
Verification middleware
apps/api/src/middleware/authenticated.ts runs on every protected route:
- Reads the token from the
tokencookie, falling back to theAuthorization: Bearerheader. - Verifies the signature and expiry.
- Loads the user by
decoded.userId. - Compares
decoded.tokenVersionwithuser.tokenVersion, and requiresstatus = active.
On success it sets req.user and req.auth = { sid } (absent for legacy tokens). Every 401 carries a code that tells the app what to do:
code | Meaning | App |
|---|---|---|
TOKEN_EXPIRED | Valid signature, past exp | Refresh and replay once |
TOKEN_INVALID | Malformed or wrongly signed | Sign out |
TOKEN_MISSING | No token sent | Sign out |
SESSION_REVOKED | tokenVersion changed, user gone or not active | Sign out |
Revocation
JWTs can't be un-issued, so revocation has two halves:
User.tokenVersion. Incrementing it invalidates every access token ever issued for the user, immediately (step 4 above).AuthSession.isActive. Setting it false makes that device's next refresh fail.
Every revoking action does both: log out all devices (settings.logoutAllDevices), password reset (passwordReset), admin suspend and revoke-sessions and account deletion (signOutEverywhere in accountAnonymize.ts). Changing a password (PUT /auth/change-password) bumps tokenVersion and revokes every other session (revokeOtherSessions). The caller gets a new access token with the same sid, so this device stays signed in. Logout ends just the one session.
AuthSession rows also power the app's "active sessions" and "login history" screens. Legacy sign-ins still create a row (with no refresh token) for that history.
Realtime authentication
esu verifies the same token from the Socket.IO handshake (apps/esu/src/auth/socketAuth.ts):
const token = socket.handshake.auth?.token;
if (!token || typeof token !== "string") throw new Error("Unauthorized");
const user = verifyToken(token);
if (!(await isSessionValid(token))) throw new Error("Session revoked");Both services must share JWT_SECRET. A mismatch shows up as sockets that connect and instantly drop.
The signature alone doesn't show a revoked token, so isSessionValid (apps/esu/src/auth/apiAuthz.ts) calls GET {API_BASE_URL}/session/authz with the user's JWT. That route runs authenticated, which checks tokenVersion and the account's status. A 401 rejects the socket. If the API is unreachable or answers 5xx, the socket is let through, because it can't join anything without the API anyway.
Revocation also reaches sockets that are already open. Logging out everywhere, changing or resetting a password, an admin suspension or session revoke, and deleting an account all call disconnectUserSockets (apps/api/src/lib/realtimeSessions.ts). That posts /admin/emit/user/disconnect to esu, which disconnects every socket in user:<id>. A device holding a fresh token reconnects; a revoked one is refused.
Room-level authorization
esu never decides access on its own. On dm:join and room:join it calls the API with the user's own JWT (apps/esu/src/auth/apiAuthz.ts):
GET {API_BASE_URL}/dm/:conversationId/authz
GET {API_BASE_URL}/room/:roomId/authz
POST {API_BASE_URL}/presence/authz { userIds } → { allowedUserIds }Any non-2xx becomes an AUTHZ_DENIED ack to the client. See Realtime.
Client-side storage and refresh
The app keeps the access token and the refresh token in the iOS Keychain / Android Keystore through expo-secure-store (apps/mobile/lib/expo-auth/storage/secure-storage.ts, keys "token" and "refreshToken"). Builds before that kept the token in AsyncStorage; the first read moves it across.
apps/mobile/lib/expo-auth/refresh.ts is the only code that spends the refresh token:
refreshSession()is single-flight. It returns the new token,signed_out(the server answered 401), orunavailable(offline, timeout, 5xx).unavailablenever signs the user out.ensureFreshToken()runs in the request interceptor of both clients (lib/axios.tsand the expo-auth client). It refreshes when fewer than 2 minutes remain, so requests rarely see a 401. It also runs when the app returns to the foreground.- On a 401
TOKEN_EXPIRED, both clients refresh and replay the request once. Any other 401, or a refresh that endssigned_out, goes to the existing path: one "Session expired" snackbar andlogout(registered byapp/(app)/_layout.tsxthroughregisterUnauthorizedHandler).logoutbest-effort callsPOST /logoutwith the refresh token, then clears both tokens and the React Query cache. - New tokens reach
AuthContextstate throughonSessionTokens. The realtime socket followsstate.token(RealtimeClient.setTokenreconnects), soesu, which reuses the handshake token for its authz calls, always holds a live one.
Cold start (AuthContext init):
- A token without a refresh token is an install from before refresh tokens. It calls
POST /token/upgradeonce (retried next launch if that fails), so updating the app signs no one out. GET /userloads the profile. Only a 401 the server meant (after the client already tried a refresh) signs the device out.- Offline, a timeout or an API outage restores the last loaded profile from AsyncStorage (
userCache, written on every successful load and cleared on logout). It is reloaded when the app returns to the foreground. With no cached profile yet, the login screen shows but the tokens are kept, so the next launch signs straight back in.
Password handling
- Hashed with bcrypt (
apps/api/src/lib/password.ts). - Never returned by any endpoint: user rows sent to their owner go through
toSelfUser(apps/api/src/lib/selfUser.ts). - At least 8 characters on register, change and reset.
- Forgotten passwords are reset with an emailed 6-digit code. See Auth → Password reset.
Secrets to get right
| Variable | Used by | Notes |
|---|---|---|
JWT_SECRET | api, esu | Must be identical. Rotating it logs everyone out. |
RESET_SECRET | api | HMAC key for stored password-reset codes. Rotating it voids outstanding codes. |
REALTIME_ADMIN_TOKEN | api, ogun, esu | Shared secret for the interservice /admin router. |
Both secrets are required outside development
apps/api/src/configs/index.ts falls back to "supersecretkey" only when ENV is development or test. Anywhere else the API refuses to start unless JWT_SECRET and RESET_SECRET are set to at least 32 characters, because anyone who knows the signing secret can mint a token for any user. Production sets both from the api-secrets sealed secret.
