Handling JWT Token Expiration and Re-authentication in Android/KMP with Ktor
A practical, step-by-step guide to building a clean, production-ready token refresh system using Ktor’s built-in Bearer Auth plugin — works…
Handling JWT Token Expiration and Re-authentication in Android/KMP with Ktor
A practical, step-by-step guide to building a clean, production-ready token refresh system using Ktor’s built-in Bearer Auth plugin — works for both Android-only and Kotlin Multiplatform (KMP) projects.
The Problem: Tokens Don’t Last Forever
If you’ve built an app that talks to a backend, you’ve probably run into this situation:
Your user logs in, gets a JWT (JSON Web Token), everything works great — until that token expires. Suddenly every API call fails with a 401 Unauthorized error. The user gets booted to the login screen even though they were just using the app two minutes ago.
That’s a terrible experience.
The fix? Automatically refresh the access token in the background before the user ever notices anything went wrong.
In this article, I’ll walk you through how to build this from scratch using Ktor as the HTTP client. The architecture works for both a plain Android app and a Kotlin Multiplatform (KMP) project targeting Android, iOS, and Desktop. I’ll highlight the differences as we go.
Android vs KMP — What’s Different?
Before we dive in, here’s a quick overview of what changes between the two setups:
+----------------------+------------------------+-----------------------------------------------+
| Area | Android Only | Kotlin Multiplatform (KMP) |
+----------------------+------------------------+-----------------------------------------------+
| HTTP Engine | OkHttp | OkHttp (Android), Darwin (iOS), Java (Desktop)|
| | | — via expect/actual |
| Token Storage | DataStore directly | DataStore with expect/actual factory per |
| | | platform |
| Coroutine Dispatcher | Dispatchers.IO | Dispatchers.IO (available via |
| | | kotlinx-coroutines-core) |
| Business Logic | Single module | Shared in commonMain, no changes needed |
| DI (Koin) | androidContext() | Platform-specific Koin init, shared modules |
+----------------------+------------------------+-----------------------------------------------+
The good news: The core token logic —
TokenProvider,TokenRefresher,HttpClientFactory— lives entirely incommonMainand is identical for both setups. Only the engine wiring and DataStore creation are platform-specific.
How JWT Auth Works (Quick Recap)
When a user logs in:
- The server sends back two tokens:
- Access Token — short-lived (minutes to hours), used for every API request.
- Refresh Token — long-lived (days to weeks), only used to get a new access token.
-
Your app stores both tokens securely.
-
Every protected API call sends the access token in the
Authorization: Bearer <token>header. -
When the access token expires, the app sends the refresh token to a special endpoint to get a brand-new access token — without asking the user to log in again.
-
If the refresh token also expires, the user has to log in again (this is expected).
Architecture at a Glance
Here’s every piece that’s involved:
TokenStorage (DataStore)
↓
TokenProvider ←→ TokenRefresher
↓ ↓
AuthStateManager nonAuthHttpClient
↓
HttpClientFactory (Ktor Auth Plugin)
↓
Every authenticated API call
Let’s build this step by step.
Step 1 — Storing Tokens Securely with DataStore
We use Jetpack DataStore to persist tokens. It’s async-safe, Kotlin-friendly, and replaces SharedPreferences. It also works in KMP via the androidx.datastore multiplatform artifact.
First, define the interface that describes what we need (this lives in commonMain for KMP, or your data layer for Android-only):
interface TokenStorage {
suspend fun getAccessToken(): String?
suspend fun getRefreshToken(): String?
suspend fun setTokens(accessToken: String, refreshToken: String)
suspend fun clear()
}
And the implementation backed by DataStore — same code for both setups:
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
class TokenStorageImpl(
private val dataStore: DataStore<Preferences>,
) : TokenStorage {
override suspend fun getAccessToken(): String? =
dataStore.data.map { it[KEY_ACCESS_TOKEN] }.first()
override suspend fun getRefreshToken(): String? =
dataStore.data.map { it[KEY_REFRESH_TOKEN] }.first()
override suspend fun setTokens(accessToken: String, refreshToken: String) {
dataStore.edit {
it[KEY_ACCESS_TOKEN] = accessToken
it[KEY_REFRESH_TOKEN] = refreshToken
}
}
override suspend fun clear() {
dataStore.edit { prefs ->
prefs.remove(KEY_ACCESS_TOKEN)
prefs.remove(KEY_REFRESH_TOKEN)
}
}
}
Why DataStore over SharedPreferences? DataStore is coroutine-first — reads and writes are suspending functions. No more blocking the main thread. It also handles concurrent writes safely.
🤖 Android Only — Creating the DataStore
For an Android-only project, you create the DataStore directly using the Android Context:
// Single instance using property delegate
private val Context.tokenDataStore: DataStore<Preferences> by preferencesDataStore(
name = "app_tokens.preferences_pb"
)
// Provide it via DI (e.g., Koin)
single<DataStore<Preferences>> { androidContext().tokenDataStore }
🌐 KMP — Platform-specific DataStore via expect/actual
In KMP, you declare an expect function in commonMain and provide actual implementations per platform:
// commonMain
expect fun createTokenDataStore(): DataStore<Preferences>
// androidMain
private val Context.tokenDataStore: DataStore<Preferences> by preferencesDataStore(
name = "app_tokens.preferences_pb"
)
private object TokenDataStoreFactory : KoinComponent {
private val context: Context by inject()
fun create(scope: CoroutineScope): DataStore<Preferences> = context.tokenDataStore
}
actual fun createTokenDataStore(): DataStore<Preferences> =
TokenDataStoreFactory.create(GlobalScope)
// iosMain
actual fun createTokenDataStore(): DataStore<Preferences> {
val documentDirectory = NSFileManager.defaultManager
.URLForDirectory(NSDocumentDirectory, NSUserDomainMask, null, true, null)
val path = requireNotNull(documentDirectory?.path) + "/app_tokens.preferences_pb"
return PreferenceDataStoreFactory.createWithPath(
produceFile = { path.toPath() }
)
}
// jvmMain (Desktop)
actual fun createTokenDataStore(): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(
produceFile = { "app_tokens.preferences_pb".toPath() }
)
Step 2 — TokenProvider: The Middle Layer
TokenProvider sits between the storage layer and the rest of the app. It keeps things clean so nothing outside the network layer talks to TokenStorage directly.
This is identical for both Android and KMP — it lives in commonMain:
interface TokenProvider {
suspend fun getAccessToken(): String?
suspend fun getRefreshToken(): String?
suspend fun saveToken(accessToken: String, refreshToken: String)
suspend fun clearToken()
}
class TokenProviderImpl(
private val storage: TokenStorage,
) : TokenProvider {
override suspend fun getAccessToken() = storage.getAccessToken()
override suspend fun getRefreshToken() = storage.getRefreshToken()
override suspend fun saveToken(accessToken: String, refreshToken: String) {
storage.setTokens(accessToken, refreshToken)
}
override suspend fun clearToken() = storage.clear()
}
Simple, clean, and easy to mock in tests.
Step 3 — Tracking Login State with AuthStateManager
We also need to track whether the user is logged in — separately from whether a token exists. We store the user ID for this.
Again, this is shared code — no difference between Android and KMP:
interface AuthStateManager {
val userId: Flow<String?>
suspend fun getUserId(): String? = userId.first()
suspend fun setUserId(userId: String)
suspend fun clearUserId()
suspend fun isLoggedIn(): Boolean = getUserId() != null
}
private val KEY_USER_ID = stringPreferencesKey("user_id")
class AuthStateManagerImpl(
private val dataStore: DataStore<Preferences>,
) : AuthStateManager {
override val userId: Flow<String?> = dataStore.data
.map { it[KEY_USER_ID] }
.distinctUntilChanged()
override suspend fun setUserId(userId: String) {
dataStore.edit { it[KEY_USER_ID] = userId }
}
override suspend fun clearUserId() {
dataStore.edit { it.remove(KEY_USER_ID) }
}
}
When a token refresh fails or the user logs out, we clear both the tokens and the user ID. This ensures the app navigates back to the login screen.
Step 4 — The Token Refresher (The Heart of It All)
This is the most important part. TokenRefresher is responsible for calling the server's /auth/refresh endpoint when the access token expires.
This code is 100% shared — identical for Android and KMP:
data class TokenPair(
val accessToken: String,
val refreshToken: String
)
interface TokenRefresher {
suspend fun tryRefresh(oldRefreshToken: String?): TokenPair?
}
The implementation has a few clever tricks:
@Serializable
private data class RefreshTokenRequest(val refreshToken: String)
@Serializable
private data class RefreshTokenResponse(val accessToken: String, val refreshToken: String)
class TokenRefresherImpl(
private val tokenProvider: TokenProvider,
private val authStateManager: AuthStateManager,
private val nonAuthHttpClient: HttpClient // ⚠️ Important! See below
) : TokenRefresher {
// Prevents multiple simultaneous refresh attempts
private val mutex = Mutex()
override suspend fun tryRefresh(oldRefreshToken: String?): TokenPair? {
return mutex.withLock {
// No refresh token = user must log in again
if (oldRefreshToken == null) {
tokenProvider.clearToken()
authStateManager.clearUserId()
return@withLock null
}
val currentAccessToken = tokenProvider.getAccessToken()
val currentRefreshToken = tokenProvider.getRefreshToken()
// Another coroutine already refreshed the tokens - reuse them!
if (currentRefreshToken != oldRefreshToken
&& currentAccessToken != null
&& currentRefreshToken != null
) {
return@withLock TokenPair(currentAccessToken, currentRefreshToken)
}
val refreshToken = currentRefreshToken ?: return@withLock null
try {
val response = nonAuthHttpClient.post("$BASE_URL/auth/refresh") {
setBody(RefreshTokenRequest(refreshToken))
}
if (response.status == HttpStatusCode.OK) {
val tokenResponse = response.body<RefreshTokenResponse>()
// Save the brand-new tokens
tokenProvider.saveToken(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken
)
TokenPair(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken
)
} else {
// Refresh failed - log the user out
tokenProvider.clearToken()
authStateManager.clearUserId()
null
}
} catch (_: Exception) {
tokenProvider.clearToken()
authStateManager.clearUserId()
null
}
}
}
}
Three Tricks Worth Calling Out
🔒 The Mutex
Imagine 3 API calls fire at the same time, and the access token is expired. Without protection, all 3 would simultaneously try to refresh the token — causing 3 refresh calls to hit the server. The Mutex ensures only one refresh happens at a time. The other two wait, and when they get the lock, they see the token was already refreshed and just reuse the new one.
♻️ The “Already Refreshed” Check
if (currentRefreshToken != oldRefreshToken
&& currentAccessToken != null
&& currentRefreshToken != null
) {
return@withLock TokenPair(currentAccessToken, currentRefreshToken)
}
This handles the waiting coroutines. If the refresh token in storage is different from the one that triggered this refresh, it means another coroutine already did the job. We just return the newly stored tokens instead of hitting the server again.
⚠️ Why nonAuthHttpClient?
We use a separate HTTP client (one without the Auth plugin) for the refresh call. If we used the authenticated client, it would try to refresh the token again when the refresh call fails — creating an infinite loop. The non-auth client just makes a plain HTTP request.
Step 5 — The HTTP Client Factory
The common plugin setup (JSON, logging, error handling) is shared code for both Android and KMP:
fun HttpClientConfig<*>.setupCommonPlugins() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
defaultRequest {
contentType(ContentType.Application.Json)
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}
expectSuccess = false
HttpResponseValidator {
handleResponseExceptionWithRequest { cause, _ ->
throw when (cause) {
is UnresolvedAddressException -> NetworkException.NoNetwork()
is ConnectTimeoutException -> NetworkException.Timeout()
is SocketTimeoutException -> NetworkException.Timeout()
is IOException -> NetworkException.Generic(cause.message)
else -> NetworkException.Unknown(cause.message)
}
}
}
}
🤖 Android Only — HTTP Engine
For Android-only, you use the OkHttp engine directly:
fun createHttpClient(shared: HttpClientConfig<*>.() -> Unit): HttpClient {
return HttpClient(OkHttp) {
shared()
}
}
🌐 KMP — Engine via expect/actual
In KMP, declare the factory as expect in commonMain:
// commonMain
expect fun createHttpClient(shared: HttpClientConfig<*>.() -> Unit): HttpClient
Then provide a platform-specific engine in each source set:
// androidMain — OkHttp engine
actual fun createHttpClient(shared: HttpClientConfig<*>.() -> Unit): HttpClient =
HttpClient(OkHttp) { shared() }
// iosMain - Darwin (NSURLSession) engine
actual fun createHttpClient(shared: HttpClientConfig<*>.() -> Unit): HttpClient =
HttpClient(Darwin) { shared() }
// jvmMain (Desktop) - Java engine
actual fun createHttpClient(shared: HttpClientConfig<*>.() -> Unit): HttpClient =
HttpClient(Java) { shared() }
On Android you can also plug in Chucker (a network inspector) inside the
androidMainimplementation by wrapping OkHttp with aChuckerInterceptor. This stays purely in the Android source set and doesn't affect any other platform.
Step 6 — Wiring It All Together with Ktor’s Bearer Auth Plugin
Ktor has a built-in plugin for Bearer token auth. This setup is the same for both Android and KMP:
fun provideNonAuthHttpClient(): HttpClient = createBaseHttpClient { }
fun provideAuthHttpClient(
tokenProvider: TokenProvider,
tokenRefresher: TokenRefresher
): HttpClient = createBaseHttpClient {
install(Auth) {
bearer {
// Called before every request - loads the stored token
loadTokens {
val accessToken = tokenProvider.getAccessToken()
val refreshToken = tokenProvider.getRefreshToken()
if (accessToken == null) return@loadTokens null
BearerTokens(accessToken, refreshToken)
}
// Called automatically when the server returns 401
refreshTokens {
val newTokens = tokenRefresher.tryRefresh(
oldRefreshToken = oldTokens?.refreshToken
) ?: return@refreshTokens null
BearerTokens(newTokens.accessToken, newTokens.refreshToken)
}
// Only send the token to our own server, not third-party URLs
sendWithoutRequest { request ->
BASE_URL.contains(request.url.host)
}
}
}
}
This is where the magic happens:
**loadTokens** — Ktor calls this before every request to attach theAuthorization: Bearer <token>header automatically. You never write the header by hand.**refreshTokens— Ktor calls this automatically when it receives a401 Unauthorizedresponse. After refreshing, Ktor retries the original request** with the new token — completely transparent to the rest of your app.**sendWithoutRequest** — Ensures you only send the token to your own server, not to third-party services (e.g., image CDNs).
Step 7 — Custom Exceptions for Clean Error Handling
Define custom exceptions so the rest of the app doesn’t have to deal with raw HTTP status codes. Shared code, same for both setups:
sealed class ApiException(message: String?) : Exception(message) {
class Unauthorized : ApiException("Invalid credentials")
class NotFound : ApiException("Resource not found")
data class ServerError(val code: Int) : ApiException("Server error $code")
}
sealed class NetworkException(message: String?) : Exception(message) {
class NoNetwork : NetworkException("No internet connection")
class Timeout : NetworkException("Request timed out")
data class Generic(val error: String?) : NetworkException(error)
data class Unknown(val error: String?) : NetworkException(error)
}
And a safeApiCall wrapper that every repository uses to avoid repetitive try-catch blocks:
suspend inline fun <reified T> safeApiCall(
crossinline apiCall: suspend () -> HttpResponse,
): Result<T> = withContext(Dispatchers.IO) {
try {
val response = apiCall()
if (response.status.isSuccess()) {
Result.success(response.body<T>())
} else {
val error = when (response.status) {
HttpStatusCode.Unauthorized -> ApiException.Unauthorized()
HttpStatusCode.NotFound -> ApiException.NotFound()
else -> ApiException.ServerError(response.status.value)
}
Result.failure(error)
}
} catch (e: NetworkException.NoNetwork) {
Result.failure(e)
} catch (e: NetworkException.Timeout) {
Result.failure(e)
} catch (e: ApiException) {
Result.failure(e)
} catch (e: Exception) {
if (e is CancellationException) throw e
Result.failure(e)
}
}
Step 8 — Dependency Injection with Koin
Here’s how all of this gets wired together. The module definition is shared for both Android and KMP:
val NonAuthClient = named("nonAuthClient")
val networkModule = module {
// A plain HTTP client - no auth, used for login/refresh endpoints
single<HttpClient>(NonAuthClient) {
provideNonAuthHttpClient()
}
// DataStore for persisting tokens
single<DataStore<Preferences>> { createTokenDataStore() }
// Storage, provider, and state manager
singleOf(::TokenStorageImpl) bind TokenStorage::class
singleOf(::TokenProviderImpl) bind TokenProvider::class
singleOf(::AuthStateManagerImpl) bind AuthStateManager::class
// Token refresher uses the non-auth client to avoid circular refresh
single<TokenRefresher> {
TokenRefresherImpl(
tokenProvider = get(),
authStateManager = get(),
nonAuthHttpClient = get(NonAuthClient)
)
}
// The main authenticated HTTP client
single<HttpClient> {
provideAuthHttpClient(
tokenProvider = get(),
tokenRefresher = get()
)
}
}
Key things here:
- We have two
HttpClientinstances: one namedNonAuthClientfor public endpoints, and a default one with Auth for protected endpoints. - Everything is a
single— the same instance is reused across the app, which is critical for the Mutex inTokenRefresherto work correctly.
🤖 Android Only — Koin initialization
// Application.kt
startKoin {
androidContext(this@MyApplication)
modules(networkModule, authModule, ...)
}
🌐 KMP — Platform-specific Koin init
// androidMain
fun initKoin(context: Context) = startKoin {
androidContext(context)
modules(networkModule, authModule, ...)
}
// iosMain (called from Swift)
fun initKoin() = startKoin {
modules(networkModule, authModule, ...)
}
Step 9 — Login and Logout
When the user logs in, save both tokens immediately:
// Inside your AuthRepository, after a successful login response
tokenProvider.saveToken(
accessToken = token.accessToken,
refreshToken = token.refreshToken
)
authStateManager.setUserId(user.id)
On logout, clear local state first (so the UI responds instantly), then tell the server:
override suspend fun logoutUser(): Boolean {
return try {
val refreshToken = tokenProvider.getRefreshToken()
// Clear locally first - don't wait for the server
tokenProvider.clearToken()
authStateManager.clearUserId()
// Best-effort server-side logout (invalidates the refresh token)
if (refreshToken != null) {
try {
authHttpClient.post("$BASE_URL/auth/logout") {
header(HttpHeaders.Authorization, "Bearer $refreshToken")
}
} catch (_: Exception) {
// Ignore server errors - we've already cleared local state
}
}
true
} catch (_: Exception) {
// Fallback - make sure local state is always cleared
tokenProvider.clearToken()
authStateManager.clearUserId()
true
}
}
Wrap logout in NonCancellable so it completes even if the ViewModel/coroutine scope is cancelled:
class LogoutUseCase(
private val authRepository: AuthRepository,
private val authStateManager: AuthStateManager
) {
suspend operator fun invoke(): Boolean = withContext(NonCancellable) {
val response = authRepository.logoutUser()
if (response) authStateManager.clearUserId()
response
}
}
How It All Flows Together
Here’s the full picture of what happens during a typical API call with an expired token:
1. ViewModel calls a repository method
2. Repository makes API call using authHttpClient
3. Ktor loads token from TokenProvider → attaches Authorization header
4. Server receives request → token is expired → returns 401
5. Ktor Auth plugin intercepts the 401
6. Ktor calls refreshTokens { }
7. TokenRefresher.tryRefresh() is called
├── Mutex.lock() — only one refresh at a time
├── POST /auth/refresh (using nonAuthHttpClient)
├── Server returns new access + refresh tokens
├── New tokens saved via TokenProvider
└── Mutex.unlock()
8. Ktor automatically retries the original request with the new token
9. Server returns 200 — success!
10. ViewModel receives the result — user noticed nothing
If the refresh fails (refresh token expired or network error):
7b. TokenRefresher clears tokens + userId
8b. Ktor returns null from refreshTokens
9b. Original request fails with 401
10b. AuthStateManager.userId flow emits null
11b. App observes null userId → navigates to login screen
Android vs KMP — Summary of Differences
+-------------------------------+------------------------------+------------------------------+
| What | Android Only | KMP |
+-------------------------------+------------------------------+------------------------------+
| createTokenDataStore() | Context.preferencesDataStore | expect/actual per platform |
| createHttpClient() | Direct HttpClient(OkHttp) | expect/actual per platform |
| HTTP Engine | OkHttp | OkHttp / Darwin / Java |
| Koin init | androidContext() | Platform-specific init funcs |
| Token / Auth / Refresh logic | Single module | commonMain, zero duplication |
| Dispatchers.IO | Available | Available via KMP coroutines |
+-------------------------------+------------------------------+------------------------------+
Key Takeaways
+---------------------------------------+-------------------------------------------------------+
| What | Why |
+---------------------------------------+-------------------------------------------------------+
| Two HTTP clients | Avoid infinite refresh loops on the refresh endpoint |
| Mutex in TokenRefresher | Prevent race conditions on simultaneous expiry |
| "Already refreshed" check | Reuse tokens from a concurrent refresh, skip server |
| Clear local state on refresh failure | Always leave the app in a clean, predictable state |
| Logout clears local first | UI responds instantly; server invalidation is |
| | best-effort |
| NonCancellable for logout | Logout completes even if ViewModel is destroyed |
| DataStore over SharedPreferences | Coroutine-safe, no main-thread blocking |
| expect/actual for platform code | Share 100% of business logic across all platforms |
+---------------------------------------+-------------------------------------------------------+
Wrapping Up
JWT token refresh sounds scary at first, but Ktor’s bearer { } plugin handles the heavy lifting. Once you set up loadTokens and refreshTokens, Ktor automatically retries failed requests with fresh tokens — your repositories and ViewModels don't even know a refresh happened.
The parts that actually need care are:
- Using a separate HTTP client for the refresh call — no loops.
- Using a Mutex to prevent duplicate refreshes.
- Checking if tokens were already refreshed by a concurrent coroutine.
If you’re building for Android only, you can skip the expect/actual parts and wire everything directly. If you're building a KMP app, the beauty is that all the token refresh logic lives in commonMain and works on every platform without any changes.
Get these three things right, and your users will never see an unexpected logout again.
메타데이터
- post_id
- d4dde837bb5e
- slug
- handling-jwt-token-expiration-and-re-authentication-in-android-kmp-with-ktor-d4dde837bb5e
- url
- https://medium.com/@prakash_ranjan/handling-jwt-token-expiration-and-re-authentication-in-android-kmp-with-ktor-d4dde837bb5e
- canonical_url
- https://medium.com/@prakash_ranjan/handling-jwt-token-expiration-and-re-authentication-in-android-kmp-with-ktor-d4dde837bb5e
- author_url
- https://medium.com/@prakash_ranjan
- status
- ok
- fetched_at
- 2026-06-18 07:02:39