Appearance
๐ Mobile Authentication Flow โ
Stack: Expo Router ยท thirdweb (
in-app wallet+ SIWE) ยท Zustand ยท Axios ยทexpo-secure-storeScope:apps/mobile(client),apps/server(session/token issuance),libs/types(shared DTOs/interfaces)
Table of Contents โ
- Overview
- File Responsibilities
- Sign-Up / Sign-In Flow
- Token Storage & Security
- Token Refresh Flow
- Cold-Start Session Restore
- Already Signed In โ Straight to Home
- Logout Flow
- Zustand Store Reference
- Backend Endpoints Used
- Best Practices Applied (and Why)
- Known Limitations / Deliberately Out of Scope
Overview โ
Touli mobile has no password login screen. The only way to authenticate is via thirdweb's in-app wallet using Google or Apple OAuth, verified server-side with a SIWE (Sign-In with Ethereum) payload. The backend still exposes a password-based /auth/login (used by other clients / for parity), but the mobile app never calls it.
High-level flow:
- User taps "Continue with Google/Apple" โ thirdweb opens the OAuth flow and returns an in-app wallet account.
- Mobile asks the backend for a SIWE payload for that wallet address (
GET /auth/thirdweb/payload). - The wallet signs the payload locally (private key never leaves the device / thirdweb SDK).
- Mobile sends the signed payload to the backend (
POST /auth/thirdweb/verify), which verifies the signature, finds-or-creates the user, and returns{ user, token }. - Mobile persists the token securely, stores the user in Zustand, and routes the user to the right screen (onboarding step, "Welcome back", or straight into the app).
Everything downstream (profile, settings, any authenticated API call) reads the session from the Zustand auth store, never from storage directly - storage is a persistence detail, not the runtime source of truth.
File Responsibilities โ
| File | Responsibility |
|---|---|
apps/mobile/src/features/auth/use-auth-store.tsx | Zustand store - single source of truth at runtime for status, user, token, isHydrating. Owns signIn, setUser, updateTokens, signOut, hydrate. |
apps/mobile/src/lib/auth/utils.tsx | Persistence layer only - getStoredToken / setStoredToken / clearStoredToken backed by expo-secure-store. No component should import this directly except the store. |
apps/mobile/src/lib/api/client.tsx | Axios instance + interceptors: attaches the bearer token to every request, and transparently refreshes an expired access token on 401 (see Token Refresh Flow). Has no dependency on the auth store - see Best Practices. |
apps/mobile/src/lib/api/auth.ts | Thin wrappers over the auth HTTP endpoints (fetchThirdwebPayload, verifyThirdwebWallet, getCurrentUser, logout). |
apps/mobile/src/lib/utils/device.ts | getDeviceId() - stable per-install UUID, used to scope refresh tokens to a physical device. |
apps/mobile/src/features/auth/signup-screen.tsx | Drives the OAuth flow end-to-end and decides where to route the user afterwards. |
apps/mobile/src/features/auth/login-screen.tsx | "Welcome back" screen shown to a returning, already-onboarded user right after OAuth verification. |
apps/mobile/src/app/index.tsx | Cold-start splash - waits for both the entrance animation and session hydration, then routes once. |
apps/mobile/src/app/(auth)/_layout.tsx, (onboarding)/_layout.tsx, (app)/_layout.tsx | Route guards per group. Deliberately do not duplicate navigation decisions already made explicitly elsewhere (see Best Practices). |
apps/server/src/routes/auth.routes.ts + controllers/auth.controller.ts + services/auth.service.ts | Issue/refresh/revoke JWTs, verify the SIWE payload, look up or create the user. |
libs/types/src/interfaces/{user,common} | Shared IUser / IToken shapes used by both mobile and server - the mobile store's user/token fields are these types verbatim, not a redefinition. |
Sign-Up / Sign-In Flow โ
mermaid
sequenceDiagram
participant U as User
participant M as Mobile (signup-screen.tsx)
participant TW as thirdweb SDK (in-app wallet)
participant API as Backend (/auth/thirdweb/*)
U->>M: Tap "Continue with Google/Apple"
M->>TW: inAppWallet().connect({ strategy })
TW-->>U: OAuth browser flow
TW-->>M: wallet + account (address)
M->>TW: getProfiles() โ email / name
M->>API: GET /auth/thirdweb/payload?address=...
API-->>M: SIWE payload
M->>TW: signLoginPayload({ account, payload })
TW-->>M: signature (signed locally, key never leaves device)
M->>API: POST /auth/thirdweb/verify { payload, signature, email, provider, deviceId }
API-->>M: { user, token: { accessToken, refreshToken } }
M->>M: store.signIn({ user, token }) โ SecureStore + Zustand
alt user.isOnboarded
M->>U: /(auth)/login ("Welcome back")
else user missing avatar/ageGroup/interests
M->>U: /(onboarding)/<next incomplete step>
endKey points reflected in signup-screen.tsx:
deviceIdcomes fromgetDeviceId()(stable per-install UUID) - not a hardcoded string. The backend scopes refresh tokens by(userId, deviceId); a shared/hardcoded device id would make two devices logging into the same account collide (logging out on one device would silently revoke the other's session too).signIn({ user, token })isawait-ed and called before anyrouter.replace(...), in the same synchronous stretch of the handler. This matters: an earlier version of this flow set the auth status via asetTimeoutafter navigating, which left a window where the route guards in(auth)/_layout.tsx/(onboarding)/_layout.tsxwould see a stalestatusnext to the new route and bounce the user to the wrong screen (in the worst case, an infinite redirect loop - see Best Practices).user.isOnboardeddecides between the "Welcome back" interstitial and resuming onboarding. The "Welcome back" screen intentionally shows even for an OAuth verification that just happened (not only a stale cached session) - it's a deliberate UX confirmation step, not a bug.
Token Storage & Security โ
| Data | Where | Why |
|---|---|---|
accessToken, refreshToken | expo-secure-store (iOS Keychain / Android Keystore) | Encrypted at rest, not readable from an unencrypted backup or a rooted/jailbroken device's app sandbox the way plain MMKV/AsyncStorage would be. |
Live copy of the token (for the Authorization header) | Zustand store, in memory | expo-secure-store reads are a native round-trip - too slow to do on every single API request. The store is the fast in-memory cache; SecureStore is the durable backing store, written-through on every change. |
user (IUser) | Zustand store only (not persisted) | Not a secret, but also not something that needs to survive a cold start on its own - it's refetched via getCurrentUser() during hydration, which also doubles as "is this session still valid" check. |
deviceId | Plain MMKV | Not a secret - just an opaque per-install identifier. |
Nothing token-shaped is ever console.log-ed anywhere in the shipped code path. (During earlier development a temporary debug log was added to grab a token for manual Bruno testing - it has since been removed; use the "Get Access Token" dev button on the Profile screen instead, which reads from the store and logs on demand rather than on every sign-in.)
Token Refresh Flow โ
apps/mobile/src/lib/api/client.tsx implements a standard single-flight refresh with a pending request queue:
mermaid
sequenceDiagram
participant R1 as Request A
participant R2 as Request B (concurrent)
participant C as client.tsx interceptor
participant API as Backend
R1->>API: GET /profile/profile (expired access token)
API-->>R1: 401
R2->>API: GET /utils/... (expired access token)
API-->>R2: 401
C->>C: isRefreshing? no โ start refresh, queue B
C->>API: POST /auth/token/refresh (bare axios call, no interceptors)
API-->>C: { token, refreshToken, deviceId }
C->>C: onTokensRefreshed() โ store.updateTokens() (SecureStore + Zustand)
C->>API: retry A with new access token
C->>API: retry B (queued) with new access token
API-->>R1: 200
API-->>R2: 200Guarantees enforced in the interceptor:
- Public auth endpoints are excluded (
login,token/refresh,password/forgot,password/reset,thirdweb/payload,thirdweb/verify). A401from one of those is a real auth failure (bad credentials, invalid signature, expired reset code) - not an expired access token - and must never trigger a refresh attempt. - At most one refresh in flight. Concurrent
401s while a refresh is already running queue behind it (pendingRequests) instead of each independently calling/auth/token/refresh. - Each request is retried at most once (
_retryflag on the Axios config) - no infinite loop if the retried request somehow 401s again. - The refresh call itself bypasses the shared
clientinstance and uses a bareaxios.post. Going throughclientwould (a) attach a possibly-already-expiredAuthorizationheader pointlessly, and (b) recursively re-enter this same response interceptor on failure. - Only a real "refresh token rejected" response signs the user out. A failed refresh call is classified before deciding whether to call
onAuthFailure():- No response at all (offline, timeout) or a
5xxfrom/auth/token/refreshsays nothing about whether the refresh token is actually invalid -refreshAccessToken()throws aTransientRefreshError, the interceptor rejects just that one original request, and the session (SecureStore + Zustand) is left untouched. The next request that hits a401simply tries the refresh again. - A
401/403from/auth/token/refreshmeans the server actually evaluated and rejected the refresh token (expired, revoked, already rotated, wrong device) - only this case callsonAuthFailure()โstore.signOut(), clearing SecureStore and Zustand state so the navigation guards route back to sign-in. - This distinction matters because of a matching backend fix:
AuthService.refreshToken()previously letjwt.verify()'sTokenExpiredError/JsonWebTokenErrorpropagate as-is, whose.messagenever matchedERRORS.INVALID_REFRESH_TOKEN.key- so an expired or malformed refresh token fell through to the controller's generic500handler instead of401. Before the client-side fix above, that500was indistinguishable from a network blip and both were treated as a hard sign-out. The service now normalizes anyjwt.verify()failure toERRORS.INVALID_REFRESH_TOKEN.keyso the controller reports401, and the client only signs out on that401/403- not on connectivity or server hiccups.
- No response at all (offline, timeout) or a
Why client.tsx doesn't import the auth store โ
use-auth-store.tsx already depends on lib/api/auth.ts (for getCurrentUser/logout), which depends on client.tsx. If client.tsx also imported the store directly to read the current token, that would be a circular import (store โ api/auth.ts โ client.tsx โ store). Instead, client.tsx exposes registerAuthHandlers({ getCurrentToken, onTokensRefreshed, onAuthFailure }), and use-auth-store.tsx calls it once, at its own module load time, wiring the store's real implementations in. client.tsx never needs to know the store exists.
Cold-Start Session Restore โ
hydrate() (in use-auth-store.tsx) runs once, kicked off at app boot (app/_layout.tsx calls hydrateAuth() at module scope):
- Read the persisted token from
expo-secure-store. None โstatus: 'signOut', done. - Optimistically set
{ token, status: 'signIn' }so the request interceptor has a token to work with. - Call
getCurrentUser()(GET /auth/user). This doubles as session validation:- Access token still valid โ succeeds immediately.
- Access token expired, refresh token still valid โ the response interceptor transparently refreshes and retries;
getCurrentUser()still resolves, caller is none the wiser. - Refresh token itself invalid/expired/revoked โ rejects.
hydrate()'scatchclears SecureStore and setsstatus: 'signOut'.
isHydratingflips back tofalsein every branch.
apps/mobile/src/app/index.tsx (the animated splash screen) waits for both the splash animation to finish and isHydrating to become false before calling navigateNext() - see the next section for why that matters.
Already Signed In โ Straight to Home โ
This is the behavior the mobile app must have on a cold start when a valid session already exists: skip signup/login entirely and land on /(app) (Home) directly.
navigateNext() in app/index.tsx already encodes this priority order:
isFirstTimeโ onboarding intro.status === 'signOut'โ signup.lastOnboardingRouteset โ resume onboarding where the user left off.- Otherwise โ
router.replace('/(app)')- straight to Home.
The fix in this rewrite was making sure step 2-4 are evaluated against post-hydration state, not whatever status happened to be on the very first render. Previously the splash animation's fixed-duration timer could finish before hydrate()'s network round trip did, so a returning user with a perfectly valid session could still momentarily be routed as if signed out. Gating navigateNext() on !isHydrating (in addition to the animation finishing) removes that race.
Note the distinction from the post-OAuth-verification case in signup-screen.tsx: a user who just completed Google/Apple sign-in and is already fully onboarded (user.isOnboarded === true) is sent to /(auth)/login (the "Welcome back" interstitial) rather than straight to /(app). That's an intentional one-time confirmation screen after a live authentication action, not the "already signed in" cold-start case above - if you want that interstitial removed too, that's a one-line change in signup-screen.tsx, but it wasn't in scope here.
Logout Flow โ
mermaid
sequenceDiagram
participant U as User
participant S as store.signOut()
participant API as POST /auth/logout
participant SS as SecureStore
U->>S: tap "Logout"
S->>API: { refreshToken, deviceId } (best-effort, Authenticate() required)
API-->>S: 200 (revokes RefreshToken row for this userId+deviceId) or error (ignored)
S->>SS: clearStoredToken()
S->>S: set({ status: 'signOut', user: null, token: null })- The backend call is best-effort: any failure (offline, 500, already revoked) is swallowed - local state is always cleared in the
finallyblock, so the user is never stuck "logged in" on the device just because the network call failed. - Logout revokes only the current device's refresh token (
RefreshTokenDao.revokeToken(userId, deviceId))- other devices signed into the same account are unaffected.
libs/types/src/dto/auth/logout.dto.tsrequires bothrefreshTokenanddeviceId; the route (POST /auth/logout) requiresAuthenticate()like every other authenticated endpoint.
Zustand Store Reference โ
ts
type AuthStatus = 'idle' | 'signOut' | 'signIn';
type AuthState = {
status: AuthStatus;
user: IUser | null; // @workspace/types/interfaces/user
token: IToken | null; // @workspace/types/interfaces/common - { accessToken, refreshToken }
isHydrating: boolean; // true until hydrate() resolves - gate cold-start navigation on this
signIn: (data: { user: IUser; token: IToken }) => Promise<void>;
setUser: (user: IUser) => void;
updateTokens: (token: IToken) => Promise<void>; // called by client.tsx after a refresh
signOut: () => Promise<void>;
hydrate: () => Promise<void>;
};Access via the generated selectors (useAuthStore.use.status(), useAuthStore.use.user(), ...) - see lib/utils/store.ts's createSelectors. Avoid subscribing to the whole store in a component that only needs one field; the per-field selectors avoid unrelated re-renders.
Backend Endpoints Used โ
| Method & Path | Auth | Mobile caller |
|---|---|---|
GET /auth/thirdweb/payload | none | fetchThirdwebPayload() |
POST /auth/thirdweb/verify | none | verifyThirdwebWallet() |
POST /auth/token/refresh | none (refresh token in body) | client.tsx's response interceptor (bare axios, not via auth.ts) |
GET /auth/user | Bearer | getCurrentUser() |
POST /auth/logout | Bearer | logout() |
All of the above have matching requests in the Bruno collection at api-collections/Touli/Auth/.
Best Practices Applied (and Why) โ
- SecureStore for secrets, Zustand for runtime state, MMKV for non-secret cache. Right tool per data sensitivity, instead of one storage mechanism for everything.
- Single-flight token refresh with a request queue, not a naive "refresh on every 401" (which would fire N simultaneous refresh calls for N concurrent failed requests and race each other).
- Transient refresh failures never sign the user out. Only a
401/403from/auth/token/refresh- a real "this refresh token is invalid" answer from the server - triggersonAuthFailure(). Network drops, timeouts, and backend5xxs fail just the in-flight request and leave the session alone, so a flaky connection can no longer log a user out from under them (see Token Refresh Flow). - Dependency injection (
registerAuthHandlers) instead of a circular import between the Axios client and the auth store - keepsclient.tsxa leaf module with no knowledge of Zustand. - Device-scoped refresh tokens use a real per-install id, not a hardcoded placeholder - this was a real bug (
deviceId: 'unknown'for every install) that would have made concurrent logins from two devices interfere with each other's sessions. - One source of truth for navigation decisions. Route guards (
_layout.tsxfiles) no longer each independently redirect based on the same shared state - that produced a genuine infinite redirect loop earlier in this project's history (see git history aroundapps/mobile/src/app/(auth)/_layout.tsx). Navigation is now driven explicitly by the screen that causes the state change (signup-screen.tsx,login-screen.tsx, onboarding's last step), and guards only handle the cases nothing else already handles. - Cold-start navigation waits for session revalidation, not just a fixed splash-animation timer, so "already signed in โ go home" can't race a slower network.
- Best-effort, fail-open logout. A failed network call during logout must never leave a user stuck signed in on their own device.
- Dead code removed, not left half-wired: the
useHasAccount/HAS_ACCOUNTflag was set on sign-up but never actually read by any navigation decision - it was deleted rather than kept "just in case".
Known Limitations / Deliberately Out of Scope โ
- No password-login screen was added to mobile - the backend supports it, but this app only authenticates via thirdweb OAuth. Wiring up password login is a separate feature decision.
expo-secure-storeis a new native dependency, pinned at the rootpackage.json(~15.0.8, matching this project's SDK 54expo@54.0.35- do not runyarn installdirectly insideapps/mobile, it has no lockfile of its own and doing so re-resolves every"*"-pinned dependency inapps/mobile/package.jsonto whatever is latest on the registry, independent of the SDK 54 pins the rest of the repo relies on). Afteryarn installat the repo root, anyone running the app still needs to rebuild the dev client (expo prebuild+ a new dev-client build) before this code will run - it will not work in an already-built dev client / Expo Go without that step.- Refresh-token rotation revokes the old token and issues a new one on every refresh, but there is no "revoke all other devices" UI yet - only per-device logout (
RefreshTokenDao.revokeAllUserTokensexists server-side but isn't exposed on any route).