Skip to content

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 ​

Rendering diagram…

Tokens ​

apps/api/src/lib/jwt.ts issues two kinds of access token:

Refreshable (mobile app)Legacy (admin, web, older app builds)
Issued bygenerateAccessToken(userId, tokenVersion, sid)generateToken(userId, tokenVersion)
LifetimeJWT_EXPIRATION_MINUTES (default 60)7 days
ClaimsuserId, tokenVersion, sid, iat, expuserId, tokenVersion, iat, exp
RenewalPOST /token/refreshSign 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 (refreshSession is single-flight).
  • Failure. Any reason (unknown, expired, revoked, user not active) answers 401 REFRESH_INVALID, which the app treats as signed out.
EndpointAuthPurpose
POST /token/refresh { refreshToken }none (rate limited, 300/IP/15 min)New access + refresh token
POST /token/upgradelegacy bearer tokenOne-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? }noneEnds that refresh session (and clears the web cookie)

Verification middleware ​

apps/api/src/middleware/authenticated.ts runs on every protected route:

  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.
  4. Compares decoded.tokenVersion with user.tokenVersion, and requires status = 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:

codeMeaningApp
TOKEN_EXPIREDValid signature, past expRefresh and replay once
TOKEN_INVALIDMalformed or wrongly signedSign out
TOKEN_MISSINGNo token sentSign out
SESSION_REVOKEDtokenVersion changed, user gone or not activeSign 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):

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

http
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), or unavailable (offline, timeout, 5xx). unavailable never signs the user out.
  • ensureFreshToken() runs in the request interceptor of both clients (lib/axios.ts and 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 ends signed_out, goes to the existing path: one "Session expired" snackbar and logout (registered by app/(app)/_layout.tsx through registerUnauthorizedHandler). logout best-effort calls POST /logout with the refresh token, then clears both tokens and the React Query cache.
  • New tokens reach AuthContext state through onSessionTokens. The realtime socket follows state.token (RealtimeClient.setToken reconnects), so esu, which reuses the handshake token for its authz calls, always holds a live one.

Cold start (AuthContext init):

  1. A token without a refresh token is an install from before refresh tokens. It calls POST /token/upgrade once (retried next launch if that fails), so updating the app signs no one out.
  2. GET /user loads the profile. Only a 401 the server meant (after the client already tried a refresh) signs the device out.
  3. 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 ​

VariableUsed byNotes
JWT_SECRETapi, esuMust be identical. Rotating it logs everyone out.
RESET_SECRETapiHMAC key for stored password-reset codes. Rotating it voids outstanding codes.
REALTIME_ADMIN_TOKENapi, ogun, esuShared 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.

Internal documentation — PlayPalz platform