Recipe: token storage and refresh rotation
Cross-platform: Kotlin Multiplatform, Flutter, React Native.
Problem
Access tokens are short-lived on purpose, so they expire mid-session. Handle that badly and you get one of two bugs: the app logs the user out on the first expiry (infuriating), or it retries a dead token forever. And the refresh token — the long-lived key to the kingdom — ends up in plain preferences or, worse, a log line.
Concept
Access token in memory, refresh token in the platform secure store, both behind one
TokenStore port. On a 401 the HTTP layer refreshes once: it exchanges the refresh token for
a new pair (the refresh token rotates, so a stolen one is single-use) and retries the
original request. Refresh is single-flight — a burst of 401s triggers one refresh, not a
stampede. A failed refresh is terminal: clear the tokens, flip a signed-out flag, and let the
app bounce to sign-in exactly once. The mutating requests already carry an idempotency key, so
the retried request is safe.
Implementation ×3
The SessionManager is the same shape everywhere: accessToken(), login(), and a
single-flight refresh() that rotates on success and signs out on failure.
KMP — data/auth/SessionManager.kt (single-flight via a Mutex)
suspend fun refresh(): Boolean = refreshMutex.withLock {
val refreshToken = store.current()?.refreshToken ?: return false
when (val result = auth.refresh(refreshToken)) {
is Outcome.Success -> { store.save(result.value); true } // rotation
is Outcome.Failure -> { store.clear(); _signedOut.value = true; false }
}
}
The executor attaches the bearer and retries once (data/http/ApiExecutor.kt):
var response = send()
if (response.status.value == 401 && session != null && session.refresh()) response = send()
Flutter — lib/data/auth.dart (single-flight via a shared Future)
Future<bool> refresh() => _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
ApiExecutor in lib/data/api.dart does if (response.statusCode == 401 && session != null && await session!.refresh()) response = await send();.
React Native — src/data/auth.ts (single-flight via a cached promise)
refresh(): Promise<boolean> {
if (!this.refreshing) this.refreshing = this.doRefresh().finally(() => { this.refreshing = null; });
return this.refreshing;
}
The ky executor recomputes the Authorization header on each send(), so the retry carries
the rotated token.
In all three, HttpAuthRepository calls /auth/login and /auth/refresh through a plain
executor with no session attached — login has no token yet, and refresh must not carry the
expired access token or it would recurse.
Comparison
The single-flight guard is the one place the platforms differ, and each uses its idiom: a
coroutine Mutex (KMP), a shared Future (Flutter), a cached Promise (React Native). All
three converge on: attach, on 401 refresh-once-and-retry, rotate, sign out on failure.
How to see it work
The rotation is unit-tested in every app: an expired access token triggers a refresh, the
stored refresh token is asserted to have changed (rotated), and the request succeeds on retry;
a failed refresh clears the store and sets the signed-out flag. KMP drives it through Ktor's
MockEngine end to end; Flutter and React Native test the SessionManager against a fake
AuthRepository.
Trade-offs
The refresh token lives in memory in the demo (InMemoryTokenStore); real mode swaps in the
platform secure store behind the same TokenStore port — no change above it. The port is
synchronous (the HTTP layer reads the access token on every request), and the platforms differ in
how they meet that: KMP's Keychain and EncryptedSharedPreferences reads are themselves synchronous
(a plain expect/actual), while Flutter's flutter_secure_storage and React Native's
expo-secure-store are async, so those two keep an in-memory mirror and write through, hydrating
the persisted session once at startup. Either way a refresh token survives a cold start and
sign-out wipes it. Rotating
on every refresh means a refresh token is single-use, which is safer but means a lost race (two
devices, one stale token) forces a real re-login; that is the correct trade for a flash sale,
where a compromised long-lived token is the scarier outcome.