Skip to content

Authentication

Stateless JWT bearer tokens, with a per-user version counter that provides revocation.

The flow

Rendering diagram…

Token shape

apps/api/src/lib/jwt.ts:

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.

PropertyValue
AlgorithmHS256 (symmetric)
Lifetime7 days
ClaimsuserId, tokenVersion, iat, exp
SecretJWT_SECRET, shared between api and esu
RefreshNone — 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:

  1. Reads the token from the token cookie, falling back to the Authorization: Bearer header.
  2. Verifies the signature and expiry.
  3. Loads the user by decoded.userId; 401 if the user no longer exists.
  4. Compares decoded.tokenVersion with user.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):

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):

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):

http
GET {API_BASE_URL}/dm/:conversationId/authz
GET {API_BASE_URL}/room/:roomId/authz

Any 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-password and POST /api/v1/reset-password are routed and reach service functions with empty bodies — password reset is not implemented. Planned

Secrets to get right

VariableUsed byNotes
JWT_SECRETapi, esuMust be identical. Rotating it logs everyone out.
RESET_SECRETapiFor password-reset tokens, once that flow exists.
REALTIME_ADMIN_TOKENapi, ogun, esuShared 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.

Internal documentation — PlayPalz platform