Handling Token Expiration in Ktor: Automatic Token Refresh for API Calls
A Complete Guide to Seamlessly Managing Token Expiry and Refresh in Ktor-based Android Apps

Introduction
In my previous article on Retrofit, I explained how to handle token expiration using OkHttp’s Authenticator
and Interceptor
. However, many modern Android applications are switching to Ktor Client as a lightweight and flexible alternative to Retrofit.
If your app relies on authentication tokens, they will eventually expire, requiring a refresh before making further API calls. Instead of manually handling this across API calls, we can automate the token refresh process in Ktor using request interceptors and a well-structured authentication mechanism.
Goals of This Guide
✅ Automatic token refresh when a 401 Unauthorized
response is received.
✅ Efficient and synchronized refresh mechanism to prevent multiple refresh calls.
✅ Seamless request retrying with the new token.
✅ Graceful failure handling, including logging out the user when necessary.
Let’s dive in! 🚀
The Problem: Expiring Tokens
Consider a scenario where your API service has multiple endpoints:
interface ApiService {
@GET("products")
suspend fun getProducts(): List<Product>
}
If the access token expires, the backend returns a 401 Unauthorized
response, causing the request to fail. The ideal solution should:
1️⃣ Detect the 401 Unauthorized
response.
2️⃣ Automatically refresh the access token using the refresh_token
.
3️⃣ Retry the original request with the new access token.
4️⃣ Log out the user if the refresh token is also expired.
Instead of handling this manually across API calls, we can leverage Ktor’s interceptors to automate the process.
Step 1: Creating an Interceptor to Add Authorization Headers
First, we need an interceptor that attaches the Authorization
header to every request.
class AuthInterceptor(private val sharedPreferences: SharedPreferences) {
fun intercept(builder: HttpRequestBuilder) {
val accessToken = sharedPreferences.getString("access_token", "") ?: ""
builder.header("Authorization", "Bearer $accessToken")
}
}
How It Works
✅ Fetches the latest access token from SharedPreferences.
✅ Appends it to the Authorization
header for every request.
Step 2: Implementing a Synchronized Token Refresh Mechanism
When a request fails with 401 Unauthorized
, we must refresh the token before retrying the request. Instead of using synchronized(this)
, we use Kotlin’s Mutex
for coroutine-safe synchronization.
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class TokenAuthenticator(
private val apiService: ApiService,
private val sharedPreferences: SharedPreferences
) {
private val lock = Mutex() // Ensures only one refresh at a time
suspend fun refreshToken(): Boolean {
return lock.withLock {
val currentAccessToken = sharedPreferences.getString("access_token", null)
val refreshToken = sharedPreferences.getString("refresh_token", null) ?: return false
// If another request has refreshed the token, return success
if (currentAccessToken != sharedPreferences.getString("access_token", null)) {
return true
}
return try {
val response = apiService.refreshToken(refreshToken)
sharedPreferences.edit()
.putString("access_token", response.accessToken)
.putString("refresh_token", response.refreshToken)
.apply()
true
} catch (e: Exception) {
false // Refresh failed, logout user
}
}
}
}
How It Works
✅ Uses Mutex.withLock {}
to prevent multiple refresh calls when multiple requests fail at the same time.
✅ Before refreshing, checks if another request has already updated the token.
✅ Calls the refresh token API, stores the new tokens, and returns true
if successful.
✅ Returns false
if the refresh fails, indicating that the user should be logged out.
Step 3: Intercepting Requests and Handling Token Expiry in Ktor
Now, we configure Ktor Client to:
- Attach authorization headers to all requests.
- Handle
401 Unauthorized
responses by triggering a token refresh. - Retry the original request automatically if the refresh succeeds.
In point 6 at the end of the current article I provide all DI examples
fun provideKtorClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
sharedPreferences: SharedPreferences
): HttpClient {
return HttpClient {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
install(DefaultRequest) {
authInterceptor.intercept(this)
}
HttpResponseValidator {
handleResponseExceptionWithRequest { exception, request ->
if (exception is ClientRequestException && exception.response.status == HttpStatusCode.Unauthorized) {
val refreshed = tokenAuthenticator.refreshToken()
if (refreshed) {
request.headers["Authorization"] = "Bearer ${sharedPreferences.getString("access_token", "")}"
throw exception // Rethrow to let Ktor retry
} else {
logoutUser() // Handle logout scenario
}
}
}
}
}
}
How It Works
✅ Attaches tokens to every request using AuthInterceptor
.
✅ Handles 401 Unauthorized errors by calling refreshToken()
.
✅ Retries the original request automatically if the token refresh succeeds.
✅ Logs out the user if the token refresh fails.
Step 4: Implementing the Refresh Token API in ApiService
Your API service should include an endpoint for refreshing tokens:
interface ApiService {
@POST("auth/refresh")
suspend fun refreshToken(@Body refreshToken: String): TokenResponse
}
data class TokenResponse(
val accessToken: String,
val refreshToken: String
)
Handling Edge Cases
Even with automatic token refresh, certain edge cases need to be managed:
1. Refresh Token Expired?
If the refresh token is also expired, the user must be logged out(or do any related to your app logic):
if (!tokenAuthenticator.refreshToken()) {
logoutUser()
}
2. Multiple Requests Fail at the Same Time?
If multiple API calls fail with 401
simultaneously, only one refresh request should be made.
✅ Solution: We use Mutex.withLock {}
in refreshToken()
to prevent duplicate refresh calls.
3. Network Failure During Token Refresh?
If the network is down when refreshing the token, we need a retry mechanism.
✅ Solution: We handle retries with exponential backoff inside HttpRequestRetry
.
Step 6: Setting Up Dependency Injection for Ktor Client
To make our AuthInterceptor
and TokenAuthenticator
reusable across the app, we should instantiate them properly. Here’s an example of how to do this using Dagger/Hilt or a simple manual dependency injection approach.
Option 1: Manual Dependency Injection (Basic Approach)
If you’re not using a dependency injection framework, instantiate the dependencies like this:
// Create SharedPreferences instance (e.g., in Application class)
val sharedPreferences: SharedPreferences =
context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
// Create ApiService instance using Ktor Client
val apiService: ApiService = provideRetrofit(sharedPreferences).create(ApiService::class.java)
// Instantiate the dependencies
val authInterceptor = AuthInterceptor(sharedPreferences)
val tokenAuthenticator = TokenAuthenticator(apiService, sharedPreferences)
// Provide the Ktor client instance
val ktorClient = provideKtorClient(authInterceptor, tokenAuthenticator, sharedPreferences)
This ensures that the same AuthInterceptor
and TokenAuthenticator
are used throughout the application.
Option 2: Using Hilt (Recommended for Larger Projects)
For projects using Hilt for dependency injection, define the necessary dependencies like this:
1️⃣ Provide SharedPreferences in a Hilt Module
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
return context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
}
}
2️⃣ Provide ApiService
(Ktor)
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideApiService(sharedPreferences: SharedPreferences): ApiService {
return provideRetrofit(sharedPreferences).create(ApiService::class.java)
}
}
3️⃣ Provide AuthInterceptor
and TokenAuthenticator
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthInterceptor(sharedPreferences: SharedPreferences): AuthInterceptor {
return AuthInterceptor(sharedPreferences)
}
@Provides
@Singleton
fun provideTokenAuthenticator(
apiService: ApiService,
sharedPreferences: SharedPreferences
): TokenAuthenticator {
return TokenAuthenticator(apiService, sharedPreferences)
}
}
4️⃣ Provide the Ktor Client
@Module
@InstallIn(SingletonComponent::class)
object KtorModule {
@Provides
@Singleton
fun provideKtorClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
sharedPreferences: SharedPreferences
): HttpClient {
return HttpClient {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
install(DefaultRequest) {
authInterceptor.intercept(this)
}
HttpResponseValidator {
handleResponseExceptionWithRequest { exception, request ->
if (exception is ClientRequestException && exception.response.status == HttpStatusCode.Unauthorized) {
val refreshed = tokenAuthenticator.refreshToken()
if (refreshed) {
request.headers["Authorization"] =
"Bearer ${sharedPreferences.getString("access_token", "")}"
throw exception // Rethrow to let Ktor retry
} else {
logoutUser() // Handle logout scenario
}
}
}
}
}
}
}
Now, your ViewModel or Repository can inject the Ktor client easily:
@HiltViewModel
class ProductsViewModel @Inject constructor(
private val ktorClient: HttpClient
) : ViewModel() {
suspend fun getProducts(): List<Product> {
return ktorClient.get("https://api.example.com/products").body()
}
}
Why This Step is Important?
- ✅ Ensures
AuthInterceptor
andTokenAuthenticator
are created once and shared across API calls. - ✅ Prevents creating new instances on each API request.
- ✅ Scales well when using DI frameworks like Hilt.
Conclusion
By implementing an interceptor-based approach, we have achieved automatic token refresh in Ktor. This ensures:
✅ Seamless token refresh without manual user intervention.
✅ Prevents multiple refresh calls by synchronizing requests.
✅ Retries failed requests automatically after refreshing the token.
✅ Handles refresh failures gracefully by logging out the user when necessary.
Final Thought
This approach provides a robust, efficient, and scalable way to manage token expiration in Ktor-based Android apps. By leveraging Ktor’s interceptors, retry mechanisms, and coroutines, we ensure a smooth, uninterrupted user experience. 🚀
Do you use a different method for handling token refresh in Ktor? Let me know in the comments below! 👇

Dobri Kostadinov
Android Consultant | Trainer
Email me | Follow me on LinkedIn | Follow me on Medium | Buy me a coffee