Authentication
Stateless JWT bearer tokens, with a per-user version counter that provides revocation.
The flow
Token shape
apps/api/src/lib/jwt.ts:
export function generateToken(userId: string, tokenVersion = 0) {
return jwt.sign({ userId, tokenVersion }, JWT_SECRET, { expiresIn: "7d" });
}The payload is intentionally minimal — a user id and a version number. Nothing about roles, subscriptions, or profile data is baked into the token, so no cached claim can go stale. Every request loads the user fresh.
| Property | Value |
|---|---|
| Algorithm | HS256 (symmetric) |
| Lifetime | 7 days |
| Claims | userId, tokenVersion, iat, exp |
| Secret | JWT_SECRET, shared between api and esu |
| Refresh | None — the client re-authenticates when the token expires |
Verification middleware
apps/api/src/middleware/authenticated.ts runs on every protected route and does four things:
- Reads the token from the
tokencookie, falling back to theAuthorization: Bearerheader. - Verifies the signature and expiry.
- Loads the user by
decoded.userId; 401 if the user no longer exists. - Compares
decoded.tokenVersionwithuser.tokenVersion; 401 with"Session expired. Please log in again."if they differ.
On success it attaches the user to req.user, typed by AuthRequest.
Revocation via tokenVersion
JWTs cannot be un-issued. The version counter solves that without a token blocklist: incrementing User.tokenVersion invalidates every token ever issued for that user, because step 4 above will fail for all of them.
POST /api/v1/auth/logout-all does exactly this (apps/api/src/services/settings.ts):
prisma.user.update({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } });
prisma.authSession.updateMany({ where: { userId }, data: { isActive: false } });Use the same mechanism for a password change, a compromised account, or a ban.
Session records are audit data, not sessions
AuthSession rows are created on register and login with device name, IP, and user agent. They power the "active sessions" and "login history" screens in the app. They are not consulted during request authentication — deactivating an AuthSession row alone does not log anyone out. Only tokenVersion does that.
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);Both services must share JWT_SECRET. A mismatch shows up as sockets that connect and instantly drop.
esu does not check tokenVersion
It verifies the signature and expiry but does not load the user, so a revoked token still opens a socket until it expires. The blast radius is limited — every room join is authorized through the API, which does check the version — but a revoked user can still receive presence broadcasts and remain in rooms they had already joined. Worth closing if you touch this code.
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/authzAny non-2xx becomes an AUTHZ_DENIED ack to the client. See Realtime.
Client-side storage
The mobile app stores the token in AsyncStorage under "token" and attaches it in an axios request interceptor (apps/mobile/lib/axios.ts). A 401 response triggers a "Session expired" snackbar.
AsyncStorage is not encrypted
expo-secure-store is already a dependency of the mobile app but the auth token is kept in AsyncStorage, which is plain unencrypted storage readable on a rooted or jailbroken device. Moving the token to SecureStore is a small, contained change and worth doing.
Password handling
- Hashed with bcrypt (
apps/api/src/lib/password.ts). - Never returned by any endpoint.
POST /api/v1/forgot-passwordandPOST /api/v1/reset-passwordare routed and reach service functions with empty bodies — password reset is not implemented. Planned
Secrets to get right
| Variable | Used by | Notes |
|---|---|---|
JWT_SECRET | api, esu | Must be identical. Rotating it logs everyone out. |
RESET_SECRET | api | For password-reset tokens, once that flow exists. |
REALTIME_ADMIN_TOKEN | api, ogun, esu | Shared secret for the interservice /admin router. |
Defaults are insecure by design
apps/api/src/configs/index.ts falls back to "supersecretkey" for both JWT_SECRET and RESET_SECRET. That is fine locally and catastrophic in production — anyone who knows the default can mint tokens for any user. Production sets both from the api-secrets sealed secret; verify that before any environment goes live.
