Skip to content

๐Ÿ” 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 โ€‹

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:

  1. User taps "Continue with Google/Apple" โ†’ thirdweb opens the OAuth flow and returns an in-app wallet account.
  2. Mobile asks the backend for a SIWE payload for that wallet address (GET /auth/thirdweb/payload).
  3. The wallet signs the payload locally (private key never leaves the device / thirdweb SDK).
  4. 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 }.
  5. 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 โ€‹

FileResponsibility
apps/mobile/src/features/auth/use-auth-store.tsxZustand store - single source of truth at runtime for status, user, token, isHydrating. Owns signIn, setUser, updateTokens, signOut, hydrate.
apps/mobile/src/lib/auth/utils.tsxPersistence 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.tsxAxios 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.tsThin wrappers over the auth HTTP endpoints (fetchThirdwebPayload, verifyThirdwebWallet, getCurrentUser, logout).
apps/mobile/src/lib/utils/device.tsgetDeviceId() - stable per-install UUID, used to scope refresh tokens to a physical device.
apps/mobile/src/features/auth/signup-screen.tsxDrives 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.tsxCold-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.tsxRoute 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.tsIssue/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>
    end

Key points reflected in signup-screen.tsx:

  • deviceId comes from getDeviceId() (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 }) is await-ed and called before any router.replace(...), in the same synchronous stretch of the handler. This matters: an earlier version of this flow set the auth status via a setTimeout after navigating, which left a window where the route guards in (auth)/_layout.tsx / (onboarding)/_layout.tsx would see a stale status next to the new route and bounce the user to the wrong screen (in the worst case, an infinite redirect loop - see Best Practices).
  • user.isOnboarded decides 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 โ€‹

DataWhereWhy
accessToken, refreshTokenexpo-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 memoryexpo-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.
deviceIdPlain MMKVNot 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: 200

Guarantees enforced in the interceptor:

  • Public auth endpoints are excluded (login, token/refresh, password/forgot, password/reset, thirdweb/payload, thirdweb/verify). A 401 from 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 (_retry flag on the Axios config) - no infinite loop if the retried request somehow 401s again.
  • The refresh call itself bypasses the shared client instance and uses a bare axios.post. Going through client would (a) attach a possibly-already-expired Authorization header 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 5xx from /auth/token/refresh says nothing about whether the refresh token is actually invalid - refreshAccessToken() throws a TransientRefreshError, the interceptor rejects just that one original request, and the session (SecureStore + Zustand) is left untouched. The next request that hits a 401 simply tries the refresh again.
    • A 401/403 from /auth/token/refresh means the server actually evaluated and rejected the refresh token (expired, revoked, already rotated, wrong device) - only this case calls onAuthFailure() โ†’ 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 let jwt.verify()'s TokenExpiredError/JsonWebTokenError propagate as-is, whose .message never matched ERRORS.INVALID_REFRESH_TOKEN.key - so an expired or malformed refresh token fell through to the controller's generic 500 handler instead of 401. Before the client-side fix above, that 500 was indistinguishable from a network blip and both were treated as a hard sign-out. The service now normalizes any jwt.verify() failure to ERRORS.INVALID_REFRESH_TOKEN.key so the controller reports 401, and the client only signs out on that 401/403 - not on connectivity or server hiccups.

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

  1. Read the persisted token from expo-secure-store. None โ†’ status: 'signOut', done.
  2. Optimistically set { token, status: 'signIn' } so the request interceptor has a token to work with.
  3. 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()'s catch clears SecureStore and sets status: 'signOut'.
  4. isHydrating flips back to false in 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:

  1. isFirstTime โ†’ onboarding intro.
  2. status === 'signOut' โ†’ signup.
  3. lastOnboardingRoute set โ†’ resume onboarding where the user left off.
  4. 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 finally block, 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.ts requires both refreshToken and deviceId; the route (POST /auth/logout) requires Authenticate() 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 & PathAuthMobile caller
GET /auth/thirdweb/payloadnonefetchThirdwebPayload()
POST /auth/thirdweb/verifynoneverifyThirdwebWallet()
POST /auth/token/refreshnone (refresh token in body)client.tsx's response interceptor (bare axios, not via auth.ts)
GET /auth/userBearergetCurrentUser()
POST /auth/logoutBearerlogout()

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/403 from /auth/token/refresh - a real "this refresh token is invalid" answer from the server - triggers onAuthFailure(). Network drops, timeouts, and backend 5xxs 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 - keeps client.tsx a 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.tsx files) 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 around apps/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_ACCOUNT flag 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-store is a new native dependency, pinned at the root package.json (~15.0.8, matching this project's SDK 54 expo@54.0.35 - do not run yarn install directly inside apps/mobile, it has no lockfile of its own and doing so re-resolves every "*"-pinned dependency in apps/mobile/package.json to whatever is latest on the registry, independent of the SDK 54 pins the rest of the repo relies on). After yarn install at 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.revokeAllUserTokens exists server-side but isn't exposed on any route).