Short answer: on a shared logistics tablet, isolate every account with a server-owned session, make switching an explicit sign-out, and treat phone OTP as one step in recovery rather than proof that the device is trusted.
The failure starts after a successful login
In a family-shared app or a warehouse dispatch app, the scary incident is rarely a rejected code. It is the next person opening the tablet and seeing the previous person's orders, addresses, or recovery phone. A cookie that survives a shift change is an account leak with a friendly UI.
The data flow should be boring: the user requests a one-time code, the server records a short-lived challenge, and a successful verification creates a new session bound to one account and one device context. Switching first revokes that session, clears local state, and returns to an account-neutral screen. The server remains the source of truth; local storage is only a cache of a session handle. It's tempting to keep a warm profile in memory for speed, but that shortcut turns a race between two taps into cross-account disclosure when a background request completes late. I keep the account ID in the response envelope and check it against the active session before every cache write, even for screens that look harmless, such as delivery preferences.
Keep it boring.
I model the boundaries before choosing a library. Here is the small TypeScript core used by a logistics client. The repository and SMS gateway are intentionally generic.
type OtpChallenge = {
id: string;
phoneHash: string;
expiresAt: number;
attempts: number;
consumed: boolean;
};
type Session = {
id: string;
accountId: string;
deviceId: string;
issuedAt: number;
};
interface AuthStore {
saveChallenge(challenge: OtpChallenge): Promise<void>;
getChallenge(id: string): Promise<OtpChallenge | null>;
consumeChallenge(id: string): Promise<void>;
createSession(accountId: string, deviceId: string): Promise<Session>;
revokeSession(sessionId: string): Promise<void>;
}
export async function verifyPhoneCode(
store: AuthStore,
challengeId: string,
submittedCode: string,
expectedCode: string,
accountId: string,
deviceId: string,
): Promise<Session> {
const challenge = await store.getChallenge(challengeId);
if (!challenge || challenge.consumed || challenge.expiresAt < Date.now()) {
throw new Error("OTP_EXPIRED");
}
if (challenge.attempts >= 5) throw new Error("OTP_LOCKED");
if (submittedCode.length !== expectedCode.length || submittedCode !== expectedCode) {
throw new Error("OTP_INVALID");
}
await store.consumeChallenge(challengeId);
return store.createSession(accountId, deviceId);
}
export async function switchAccount(store: AuthStore, session: Session): Promise<void> {
await store.revokeSession(session.id);
// The client must also clear cached queries and return to a neutral route.
}
The example deliberately consumes a challenge before issuing a session. In production, make that operation atomic, hash codes at rest, rate-limit requests by phone and device, and use a constant-time comparison helper from your runtime. The exact five-attempt policy is a starting point, not a universal law; your threat model and support process should set it.
How should session isolation and safe account switching work on a shared device?
Give each successful verification a fresh, opaque session identifier. Keep the account ID server-side and rotate the session after authentication. A client should never decide that two accounts are interchangeable because they share a phone number, and it should never infer identity from a cached profile object.
Switching is a transaction, not a navigation event. Revoke the old session, delete refresh tokens, clear encrypted local caches, cancel in-flight requests, and invalidate the query cache before rendering the phone-entry screen. If the network is unavailable, show an account-neutral locked state; do not keep showing private data while waiting to revoke. On a family-shared tablet, this includes attachments and notification previews that the operating system may have cached outside your app. On a warehouse tablet, it includes offline route manifests and barcode history. I write a test for each data class because a clean home screen can still leave a private image in a native preview surface.
A useful test is to interrupt every transition: kill the process after revocation but before the new login, rotate the device clock, press back during code entry, and open two tabs with different accounts. The expected result is either no private data or data belonging to exactly one still-valid session. Anything ambiguous deserves a failing test.
Recovery paths matter more than the happy path
Phone OTP proves control of a number at that moment. It does not prove that the person owns the tablet, that the SIM is safe, or that an old number should retain access. Recovery should therefore be explicit: offer a second verified factor, a support-reviewed identity check, or an organization-managed reset for warehouse devices. Record which path was used and notify the account through an independent channel when possible.
The catch is operational. A second factor can strand a driver with a dead phone, while a support override can become social engineering. Choose a path that your team can audit at 02:00, and document who may approve it. Stick with a simpler OTP-only flow when the app holds low-risk data and the business accepts number-recycling risk; add stronger recovery when it holds payment, health, or location history.
Measure the whole exchange, not just code delivery. Log challenge creation, send latency, verification latency, revocation result, and the reason for every failed attempt, with phone numbers redacted or hashed. Set a budget for SMS retries; otherwise a flaky radio link can turn one tap into a burst of paid messages.
Keep the boundary in plain HTTP and standards-based tokens so a vendor change does not rewrite the mobile client. Your mileage may vary on delivery time by country and carrier, so load-test the timeout and retry policy with synthetic numbers before rollout. I am not sure any single provider can guarantee recovery quality across every region; that uncertainty belongs in the runbook, not hidden in a dashboard.
A release checklist that survives shift change
Before shipping, verify that OTPs expire, are single-use, and are throttled; that sessions rotate and revoke; and that push notifications, deep links, screenshots, and backups cannot expose another account. Run a scripted handoff test with two users on one tablet, including an offline switch and a power loss. Review retention for challenge logs, give support a reversible reset procedure, and alert on repeated OTP_LOCKED events without logging secrets.
The decision rule is straightforward: select the least complex recovery path that protects the data you actually store, then spend engineering time on revocation and cache clearing. Those are the parts users experience as “safe switching,” and they are where shared-device authentication usually fails.













