Blog Infos
Author
Published
Topics
, , , ,
Published
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 and TokenAuthenticator are created once and shared across API calls.
  • ✅ Prevents creating new instances on each API request.
  • ✅ Scales well when using DI frameworks like Hilt.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

No results found.

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

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE
Menu