Skip to content
Secure Mesh Docs
Esc
navigateopen⌘Jpreview
On this page

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-mmkv with an OS Keychain/Keystore-held encryption key (expo-secure-store).
  • The Session model (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/me identity 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_identity is only referenced here as a storage key that logout clears).

Overview

The subsystem answers three questions for the mobile client:

  1. 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 via expo-secure-store (Keychain on iOS, Keystore-backed EncryptedSharedPreferences on Android).
  2. How does the client authenticate?login() and register() POST credentials to the backend, receive a Session, and persist it. Every subsequent request reads the access token from storage and attaches it as a Bearer token. When the server returns 401, the client transparently refreshes the token once and retries the original request.
  3. What is the crypto identity? — The Session carries an identity_seed (and logout() also clears an sm_e2ee_identity record). 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 like auth.ts never see the encryption details.
  • auth.ts — the session controller. It persists/clears session data, exposes login, register, logout, getToken, isAuthed, and the internal request() helper used for all authenticated HTTP calls.
  • auth/refresh.ts — the token-refresh routine invoked when a request fails with 401; on success the retried request proceeds with the new access token.
  • realtime.ts — passes getToken as the gRPC tokenProvider, reusing the same session store for real-time connections.
  • useGroupMessages.ts — resolves the current user id via /auth/me through the same authenticated HTTP path.
  • validation.ts — defines the Zod loginSchema used to validate form input before it reaches login().

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

  1. First launch: getOrGenerateKey() calls SecureStore.getItem('sm_mmkv_key'). Because nothing exists yet, it fabricates a key from Math.random() + Date.now() and persists it with SecureStore.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.
  2. Subsequent launches: the key is read back from the OS secure store and handed to createMMKV() as encryptionKey. MMKV uses it to encrypt the entire database file (secure-mesh-storage) at rest.
  3. Synchronous access: the exported storage object is used directly with getString, 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 than AsyncStorage or localStorage, 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

  1. Read tokenstorage.getString('access_token') is a synchronous MMKV read; no async gap between deciding to send a request and attaching credentials.
  2. Send requestnitroFetch (from react-native-nitro-fetch) issues the HTTP call with Content-Type: application/json and, if a token exists, Authorization: Bearer <token>. Public endpoints (/auth/login, /auth/register) simply send no Authorization header.
  3. Parse response — JSON is parsed defensively (res.json().catch(() => ({}))) so an HTML error page or empty body cannot crash the caller.
  4. Handle failure — on a non-2xx status, a 401 triggers the refresh path exactly once (guarded by the retried flag): refreshAccessToken() is called, and if it succeeds the original request is replayed with retried = true. Any other status (or a failed refresh) throws an Error carrying the server’s error/message field, falling back to HTTP <status>.
  5. 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’s error/message text or HTTP <status>; a 401 is 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_identity are 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-request storage.getString) are synchronous MMKV reads — no bridge round-trip, no promise churn. This is critical because getToken runs 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 synchronous remove calls; no async teardown or key rotation is required before the UI can navigate away.
  • Operational note: because device_id is 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 strategyauth/refresh.ts is 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 storage instance (as the E2EE layer does with sm_e2ee_identity); the encrypted-at-rest guarantee is automatic.
  • Custom fetchrequest() is built on nitroFetch; the module boundary isolates the networking library so it could be replaced while keeping the 401-refresh logic intact.
  • ValidationloginSchema in validation.ts is the natural place to extend input rules (e.g., password strength) before credentials reach login().
  • gRPC authrealtime.ts accepts tokenProvider: 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).

Was this page helpful?