Mobile Auth, Crypto & Secure Storage
Mobile Auth, Crypto & Secure Storage
This page documents the SkyConnect mobile app’s authentication, cryptographic identity, and secure storage subsystem — the client-side foundation that handles login/registration, session persistence, encrypted key-value storage, token refresh, and the bearer-token plumbing used by the HTTP and gRPC layers.
Purpose and Scope
The mobile app is a React Native / Expo client for the SkyConnect streaming platform. This page covers the capability implemented by the streaming/src/lib/auth.ts module, the encrypted MMKV storage in streaming/src/lib/storage/mmkv.ts, and the modules that depend on them (streaming/src/lib/auth/refresh.ts, streaming/src/lib/realtime.ts, streaming/src/lib/useGroupMessages.ts, streaming/src/lib/validation.ts).
Topics included:
- Encrypted key-value storage backed by
react-native-mmkvwith an OS Keychain/Keystore-held encryption key (expo-secure-store). - The
Sessionmodel (access token, refresh token, user id, identity seed) and its persistence lifecycle. - Login, registration, logout, token retrieval, and the authenticated HTTP request helper with automatic 401-driven refresh-and-retry.
- Integration points: gRPC token provider,
/auth/meidentity lookup, and Zod login validation.
Topics intentionally left to sibling pages:
- Server-side authentication endpoints (
/auth/login,/auth/register,/auth/refresh) and their authorization logic — see the backend auth catalog pages. - Real-time messaging, media streaming, and group message features that merely consume tokens — see Mobile Realtime & Media pages.
- The end-to-end encryption message layer (
sm_e2ee_identityis only referenced here as a storage key that logout clears).
Overview
The subsystem answers three questions for the mobile client:
- Where do secrets live? — Session tokens and the identity seed are written to a local MMKV instance (
id: 'secure-mesh-storage') that is encrypted at rest. The encryption key itself never touches disk in plaintext: it is generated on first launch and stored in the OS secure enclave viaexpo-secure-store(Keychain on iOS, Keystore-backed EncryptedSharedPreferences on Android). - How does the client authenticate? —
login()andregister()POST credentials to the backend, receive aSession, and persist it. Every subsequent request reads the access token from storage and attaches it as aBearertoken. When the server returns401, the client transparently refreshes the token once and retries the original request. - What is the crypto identity? — The
Sessioncarries anidentity_seed(andlogout()also clears ansm_e2ee_identityrecord). These are the root material for the app’s E2EE identity, stored only in the encrypted MMKV so it survives app restarts but is destroyed on logout.
The design intent is defense-in-depth: even though MMKV is a fast, synchronous, on-device key-value store, it is encrypted, and the key-management problem is delegated to the operating system’s secure storage rather than reimplemented in JavaScript. This keeps secrets out of plaintext backups and out of JS bundle inspection while preserving synchronous, low-latency reads on the app’s hot paths.
Architecture
Component roles:
storage/mmkv.ts— owns the single encrypted MMKV instance. It is the only module that touches the secure-storage plumbing, so callers likeauth.tsnever see the encryption details.auth.ts— the session controller. It persists/clears session data, exposeslogin,register,logout,getToken,isAuthed, and the internalrequest()helper used for all authenticated HTTP calls.auth/refresh.ts— the token-refresh routine invoked when a request fails with401; on success the retried request proceeds with the new access token.realtime.ts— passesgetTokenas the gRPCtokenProvider, reusing the same session store for real-time connections.useGroupMessages.ts— resolves the current user id via/auth/methrough the same authenticated HTTP path.validation.ts— defines the ZodloginSchemaused to validate form input before it reacheslogin().
The storage dependency is deliberately one-directional: auth.ts imports storage, but storage never imports auth. This keeps the secure layer reusable (e.g., sm_e2ee_identity for the E2EE module) and prevents circular imports between identity and persistence concerns.
Secure Storage Layer: Encrypted MMKV
The entire persistence strategy rests on a single module, streaming/src/lib/storage/mmkv.ts, which wires react-native-mmkv to expo-secure-store:
import { createMMKV } from 'react-native-mmkv';
import * as SecureStore from 'expo-secure-store';
const ENCRYPTION_KEY_ALIAS = 'sm_mmkv_key';
function getOrGenerateKey(): string {
let key = SecureStore.getItem(ENCRYPTION_KEY_ALIAS);
if (!key) {
key = Math.random().toString(36).substring(2) + Date.now().toString(36);
SecureStore.setItem(ENCRYPTION_KEY_ALIAS, key);
}
return key;
}
export const storage = createMMKV({
id: 'secure-mesh-storage',
encryptionKey: getOrGenerateKey(),
});
Source: streaming/src/lib/storage/mmkv.ts
How it works
- First launch:
getOrGenerateKey()callsSecureStore.getItem('sm_mmkv_key'). Because nothing exists yet, it fabricates a key fromMath.random()+Date.now()and persists it withSecureStore.setItem(...). On iOS this stores the key in the Keychain; on Android it is stored in Keystore-backed secure preferences, i.e., outside the app sandbox’s plaintext files. - Subsequent launches: the key is read back from the OS secure store and handed to
createMMKV()asencryptionKey. MMKV uses it to encrypt the entire database file (secure-mesh-storage) at rest. - Synchronous access: the exported
storageobject is used directly withgetString,set,remove— no async ceremony, which matters for the token reads that happen inside every request and gRPC connection handshake.
Design intent
- Key separation: the MMKV encryption key is never stored inside MMKV itself. If an attacker obtains the on-disk MMKV file (backup, forensic extraction), it is useless without the OS-enclave key.
- Single point of trust: all secret material —
access_token,refresh_token,user_id,identity_seed,sm_e2ee_identity— funnels through this one encrypted store rather thanAsyncStorageorlocalStorage, which are unencrypted. - Cost trade-off: the key is generated with
Math.random(), which is not cryptographically secure — a pragmatic choice for a client-side obfuscation key whose real security derives from the OS Keychain wrapping, not the entropy source. See Failure Modes for the implications.
Session Model and Auth Lifecycle
auth.ts defines the shape of a session and every operation that manages it:
export interface Session {
access_token: string;
refresh_token: string;
user_id: string;
identity_seed: string;
}
Source: streaming/src/lib/auth.ts
The four fields map to distinct concerns:
| Field | Purpose |
|---|---|
access_token |
Short-lived bearer credential attached to every authenticated request |
refresh_token |
Long-lived credential exchanged for a new access token on 401 |
user_id |
Stable server-side identity, also resolved via /auth/me |
identity_seed |
Root secret for the client’s E2EE identity; kept only in encrypted storage |
Persistence
persist() writes all four fields into the encrypted MMKV in one place, so the session is atomically represented from the storage layer’s perspective:
function persist(session: Session) {
storage.set('access_token', session.access_token);
storage.set('refresh_token', session.refresh_token);
storage.set('user_id', session.user_id);
storage.set('identity_seed', session.identity_seed);
}
Source: streaming/src/lib/auth.ts
Login and registration
Both entry points share the same shape: POST credentials plus a fixed device_id: 'mobile-app' to the backend, then persist the returned session:
export async function login(email: string, password: string): Promise<void> {
const data = await request('/auth/login', 'POST', { email, password, device_id: 'mobile-app' });
persist(data);
}
export async function register(username: string, email: string, password: string): Promise<void> {
const data = await request('/auth/register', 'POST', { username, email, password, device_id: 'mobile-app' });
persist(data);
}
Source: streaming/src/lib/auth.ts
The hardcoded device_id: 'mobile-app' tells the backend this is a mobile client; there is no per-install device registration in this module, so server-side device rotation/revocation is out of scope for this page.
Logout
logout() is the counterpart of persist(): it removes every session key and the E2EE identity record, guaranteeing that signing out wipes all secret material from the device:
export function logout(): void {
storage.remove('access_token');
storage.remove('refresh_token');
storage.remove('user_id');
storage.remove('identity_seed');
storage.remove('sm_e2ee_identity');
}
Source: streaming/src/lib/auth.ts
Note that the MMKV encryption key (sm_mmkv_key, held in the OS Keychain) is deliberately not removed on logout — it is reused across sessions. This avoids re-generating the key on every login, but means a wiped session store is still encrypted under the same device key.
Token accessors
export function getToken(): string {
return storage.getString('access_token') ?? '';
}
export function isAuthed(): boolean {
return !!storage.getString('access_token');
}
Source: streaming/src/lib/auth.ts
getToken() is the bridge to the gRPC layer: realtime.ts passes it as tokenProvider so every real-time connection authenticates with the same access token from the same encrypted store (see streaming/src/lib/realtime.ts). isAuthed() gives the UI a cheap synchronous gate for routing to login screens.
Core Flow: Authenticated Request with 401 Refresh-and-Retry
Every authenticated HTTP call in the app funnels through the private request() helper. It is the heart of the subsystem’s runtime behavior:
async function request(path: string, method: string, body?: any, retried = false): Promise<any> {
const token = storage.getString('access_token');
const res = await nitroFetch(`${API_BASE_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 && !retried) {
const ok = await refreshAccessToken();
if (ok) return request(path, method, body, true);
}
throw new Error(data.error || data.message || `HTTP ${res.status}`);
}
return data;
}
Source: streaming/src/lib/auth.ts
Walkthrough
- Read token —
storage.getString('access_token')is a synchronous MMKV read; no async gap between deciding to send a request and attaching credentials. - Send request —
nitroFetch(fromreact-native-nitro-fetch) issues the HTTP call withContent-Type: application/jsonand, if a token exists,Authorization: Bearer <token>. Public endpoints (/auth/login,/auth/register) simply send no Authorization header. - Parse response — JSON is parsed defensively (
res.json().catch(() => ({}))) so an HTML error page or empty body cannot crash the caller. - Handle failure — on a non-2xx status, a
401triggers the refresh path exactly once (guarded by theretriedflag):refreshAccessToken()is called, and if it succeeds the original request is replayed withretried = true. Any other status (or a failed refresh) throws anErrorcarrying the server’serror/messagefield, falling back toHTTP <status>. - Return data — success returns the parsed JSON body to the caller (e.g., the session object for login/register).
Sequence diagram
The retried flag is the anti-loop guard: without it, a stale refresh token would cause an infinite 401 → refresh → 401 cycle. The flag also ensures the refresh endpoint itself (which is unauthenticated or uses the refresh token) is never recursively retried.
Integration Points
gRPC token provider
realtime.ts reuses the same session store for real-time connections by passing getToken as the token provider:
tokenProvider: getToken,
Source: streaming/src/lib/realtime.ts
This means the gRPC gateway authenticates with the current access token at connection time — the same token the HTTP layer uses — so a single login() covers both request/response and streaming surfaces.
Identity lookup
useGroupMessages.ts resolves the current user through the authenticated HTTP path:
const me = await apiService.get<{ id: string | number }>('/auth/me');
Source: streaming/src/lib/useGroupMessages.ts
/auth/me is called with the same Bearer token; the returned id is cached in a ref for the message-list lifetime.
Input validation
validation.ts defines the Zod schema that gates the login form:
export const loginSchema = z.object({
email: z.email('Invalid email address'),
});
Source: streaming/src/lib/validation.ts
Validation happens before login() is invoked, so malformed input never reaches the network layer, keeping auth.ts free of form concerns.
Usage Examples
Logging in and checking state (typical UI flow)
// From a login form handler after Zod validation passes:
await login(email, password);
if (isAuthed()) {
// navigate to the main app; getToken() is now available
// for the gRPC layer via tokenProvider
}
The underlying behavior — login() posting to /auth/login and persisting the session — is implemented in streaming/src/lib/auth.ts.
Registering a new account
await register('sky_user', 'sky@example.com', 'hunter2');
// session persisted; app is authenticated without a separate login call
Implementation: streaming/src/lib/auth.ts.
Signing out and wiping secrets
logout();
// removes access_token, refresh_token, user_id, identity_seed, sm_e2ee_identity
Implementation: streaming/src/lib/auth.ts.
Reading and writing the encrypted store directly
import { storage } from './storage/mmkv';
// Any module may persist sensitive values in the encrypted KV store:
storage.set('sm_e2ee_identity', myIdentityBundle);
const identity = storage.getString('sm_e2ee_identity');
The encrypted store instance is created in streaming/src/lib/storage/mmkv.ts.
Configuration Options
The subsystem’s configuration is minimal by design — the secure layer is intentionally self-contained. All values are set in code; there are no runtime toggles.
| Option | Location | Value / Default | Description |
|---|---|---|---|
ENCRYPTION_KEY_ALIAS |
mmkv.ts | 'sm_mmkv_key' |
Alias under which the MMKV encryption key is stored in expo-secure-store |
MMKV id |
mmkv.ts | 'secure-mesh-storage' |
On-disk database id for the encrypted MMKV instance |
device_id |
auth.ts | 'mobile-app' |
Static device identifier sent with login/register payloads |
| Storage keys | auth.ts | access_token, refresh_token, user_id, identity_seed |
Session keys written by persist(); plus sm_e2ee_identity cleared by logout() |
API_BASE_URL |
auth.ts | imported from ../config/env |
Base URL for all HTTP requests (/auth/login, /auth/register, /auth/refresh, /auth/me) |
GRPC_URL |
realtime.ts | imported from ../config/env |
gRPC gateway URL consumed by the realtime layer, which reuses getToken() |
API Reference
storage (exported MMKV instance)
storage: MMKV — the single encrypted key-value store (id: 'secure-mesh-storage') created by mmkv.ts. All session secrets and E2EE identity material are persisted through it. Supports getString, set, remove, and other standard MMKV methods synchronously.
login(email: string, password: string): Promise<void>
POSTs /auth/login with { email, password, device_id: 'mobile-app' } and persists the returned Session.
Parameters:
email(string): user’s email address.password(string): user’s password.
Throws:
Error: on non-2xx response, with the server’serror/messagetext orHTTP <status>; a401is transparently retried once after token refresh before throwing.
Source: streaming/src/lib/auth.ts
register(username: string, email: string, password: string): Promise<void>
POSTs /auth/register with { username, email, password, device_id: 'mobile-app' } and persists the returned Session.
Throws: same error contract as login().
Source: streaming/src/lib/auth.ts
logout(): void
Synchronously removes access_token, refresh_token, user_id, identity_seed, and sm_e2ee_identity from the encrypted store.
Source: streaming/src/lib/auth.ts
getToken(): string
Returns the current access token, or '' when unauthenticated. Used as the gRPC tokenProvider.
Source: streaming/src/lib/auth.ts
isAuthed(): boolean
Returns true iff an access_token exists in storage.
Source: streaming/src/lib/auth.ts
refreshAccessToken(): Promise<boolean> (in auth/refresh.ts)
Imported by auth.ts and invoked on a 401. Resolves true when a new access token was obtained and persisted, enabling the retried request.
Source: streaming/src/lib/auth.ts
Session interface
interface Session {
access_token: string;
refresh_token: string;
user_id: string;
identity_seed: string;
}
Source: streaming/src/lib/auth.ts
Failure Modes, Edge Cases & Concurrency
401 storm / refresh loop
If refreshAccessToken() returns false (invalid or expired refresh token), the request throws instead of retrying. The retried boolean is the hard guard against infinite recursion — the refresh call itself can never trigger another refresh because the retried request path is the only place that calls it. A permanently rejected refresh token therefore surfaces as a single Error; the app layer should respond by routing the user back to login.
Key loss on app data wipe / reinstall
The MMKV encryption key lives in the OS secure store. If the user uninstalls the app (iOS Keychain items can survive reinstall depending on access-group policy) or wipes app data, SecureStore.getItem('sm_mmkv_key') returns null and a new key is generated. Consequences:
- The old MMKV file is unreadable — all session data and the
sm_e2ee_identityare effectively destroyed. - The user must log in again and re-establish E2EE identity from the server.
- This is acceptable for a session store but is a reason the identity seed should be recoverable from the backend rather than device-only.
Non-cryptographic key generation
getOrGenerateKey() builds the key from Math.random().toString(36) + Date.now().toString(36) — not CSPRNG output. Security here relies on the OS Keychain/Keystore’s wrapping and the fact that the key never leaves secure storage, not on the entropy of the generator. If the threat model demands a stronger source, replace the generator with expo-crypto’s getRandomBytes while keeping the same storage flow.
Concurrent access
MMKV is synchronous and in-process, so reads/writes from multiple JS modules (auth.ts, realtime.ts, useGroupMessages.ts, E2EE module) are serialized by the native MMKV implementation; there is no async race between getToken() and persist(). The one subtlety is the 401-refresh path: two concurrent requests can each observe a 401 and both trigger refreshAccessToken(). The result is idempotent (both end up with a valid token; the last write wins), but it can cause a redundant refresh call — a known, benign behavior of this design.
Empty / malformed response bodies
res.json().catch(() => ({})) guarantees data is always an object, so data.error || data.message || \HTTP ${res.status}`never dereferencesnull. Requests without a body pass body: undefined, which nitroFetch` treats as a bodiless request.
Unauthenticated calls
When no token exists, request() simply omits the Authorization header. This is what makes /auth/login and /auth/register work — they are the only endpoints that must be reachable pre-authentication.
Performance & Operational Considerations
- Zero async overhead on hot paths: token reads (
getToken,isAuthed, and the in-requeststorage.getString) are synchronous MMKV reads — no bridge round-trip, no promise churn. This is critical becausegetTokenruns on every gRPC connection handshake and every HTTP request. - Single encrypted file: all secrets live in one MMKV database, so the native layer encrypts/decrypts one file rather than many. MMKV memory-maps the file, keeping reads fast after first touch.
- One-shot refresh: a 401 adds at most one extra round-trip (
/auth/refresh) before replaying the original request; there is no exponential backoff or queue, which is appropriate for a mobile client that should fail fast and let the user re-authenticate. - Logout is O(1):
logout()issues five synchronousremovecalls; no async teardown or key rotation is required before the UI can navigate away. - Operational note: because
device_idis the constant string'mobile-app', the backend cannot distinguish devices of the same user from this field alone. Multi-device session management, if needed, would require a real per-install id generated at first launch and stored alongside the encryption key.
Extension Points
- Token refresh strategy —
auth/refresh.tsis the single seam for refresh behavior. Swapping the refresh endpoint, adding retry/backoff, or persisting a rotated refresh token all happen there without touching callers. - Additional secure values — any module can store sensitive data in the exported
storageinstance (as the E2EE layer does withsm_e2ee_identity); the encrypted-at-rest guarantee is automatic. - Custom fetch —
request()is built onnitroFetch; the module boundary isolates the networking library so it could be replaced while keeping the 401-refresh logic intact. - Validation —
loginSchemainvalidation.tsis the natural place to extend input rules (e.g., password strength) before credentials reachlogin(). - gRPC auth —
realtime.tsacceptstokenProvider: getToken; a different provider (e.g., one that auto-refreshes on expiry) can be injected without changing the realtime layer.
Tests
No test files for this subsystem were found in the repository search. The behavioral contracts described here — session persistence, 401 refresh-and-retry with the single-retry guard, key generation/rotation in mmkv.ts, and the logout wipe list — are the natural starting points for future unit tests (mock storage and nitroFetch, assert request() calls refresh at most once, assert persist/logout key sets are exact).
Related Links
- Encrypted storage implementation
- Auth session controller
- Token refresh module
- Realtime gRPC integration
- Group messages identity lookup
- Zod login validation
- For server-side auth endpoints and authorization rules, see the backend authentication catalog pages.
- For message E2EE and real-time media features, see the Mobile Realtime & Media pages.