Introduction
Network calls in Android apps need more than just a base URL and request body. You'll need to inject authentication tokens, log requests during debugging, handle expired credentials, and enforce timeouts—all without duplicating code across every HTTP call your team makes. OkHttp interceptors: a practical Android & Kotlin guide shows you how to layer these concerns cleanly, letting your business logic stay focused while infrastructure handles the rest.
We've used OkHttp interceptors across e-commerce, SaaS, and travel platforms to keep our networking code maintainable as requirements evolved. Whether you're adding authorization headers, debugging stubborn API issues, or rotating credentials transparently, interceptors give you a single place to reason about how requests flow through your app.
Auth interceptor adds tokens
The most common interceptor you'll write adds authentication headers to every outgoing request. Rather than manually appending tokens in each API call, an interceptor intercepts the request chain and enriches it once.
Here's a practical example. You're building a shopping app where users log in and receive a JWT. Every subsequent request needs that token in the Authorization header:
class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.getToken()
val originalRequest = chain.request()
val authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(authenticatedRequest)
}
}TokenProvider might read from SharedPreferences, encrypted storage, or an in-memory cache. The key point: the interceptor doesn't care. It just asks for the current token and applies it.
Then register it as an application interceptor (not a network interceptor—we'll explain the difference in a moment):
val httpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.shop.local/")
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()Now every GET, POST, PUT, and DELETE request automatically carries your token. Your Retrofit service definitions stay clean:
interface ShopApi {
@GET("products")
suspend fun listProducts(): List
}Application interceptors (added via addInterceptor) run before OkHttp's internal logic, so they see the request as your code built it. This is where you add headers, modify URLs, or log. Network interceptors (added via addNetworkInterceptor) run after OkHttp has already processed compression and redirects, so they see the actual bytes going over the wire—useful for deep debugging.
Logging only in debug builds
OkHttp ships with a logging interceptor, but you need to control when it runs. Logging every request body in production is wasteful and potentially exposes sensitive data in crash reports.
Add the OkHttp logging dependency to your build.gradle:
dependencies {
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}Then conditionally attach it:
val httpClientBuilder = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
if (BuildConfig.DEBUG) {
val loggingInterceptor = HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
httpClientBuilder.addInterceptor(loggingInterceptor)
}
val httpClient = httpClientBuilder.build()The Level.BODY setting logs full request and response bodies, headers, and metadata. Other levels include HEADERS (headers only), BASIC (just the request/response line), and NONE (nothing). In development, BODY is invaluable for debugging API contract mismatches or malformed JSON.
One team we worked with had logging at BODY level enabled in staging but not production, then switched to HEADERS level in production for deployments where they suspected network issues. The flexibility of interceptors let them avoid conditional code branches in the service layer—the logging concern was truly orthogonal.
Authenticator refreshes expired tokens
Tokens expire. When a 401 response comes back from the API, you have two choices: prompt the user to log in again, or transparently refresh the token and retry the request. An Authenticator is OkHttp's mechanism for the latter.
This is distinct from an Interceptor. An interceptor runs on every request; an authenticator runs only when you're about to receive a 401 and gives you a chance to supply fresh credentials.
class TokenAuthenticator(private val tokenService: TokenService) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.code != 401) return null
val refreshToken = tokenService.getRefreshToken()
if (refreshToken == null) {
// User must log in again
return null
}
return try {
val newAccessToken = tokenService.refresh(refreshToken).accessToken
tokenService.saveAccessToken(newAccessToken)
// Rebuild the original request with the new token
response.request.newBuilder()
.header("Authorization", "Bearer $newAccessToken")
.build()
} catch (e: Exception) {
// Refresh failed; let the 401 propagate
null
}
}
}Register it on the client:
val httpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.authenticator(TokenAuthenticator(tokenService))
.build()Now the flow is: request goes out with an expired token → API returns 401 → the authenticator's authenticate function is called → it refreshes the token → the original request is retried with the new token → response is returned to the caller, who has no idea a refresh happened.
A critical detail: the refresh endpoint itself must not trigger the authenticator loop. Usually that endpoint is unauthenticated or uses a separate token. If your refresh endpoint also returns 401 after the authenticator tries to call it, returning null from authenticate stops the loop and lets the 401 propagate, forcing a logout.
Timeouts on every client
Network operations can hang indefinitely if the connection drops or the server stalls. OkHttp provides separate timeouts for connection establishment, reading from the socket, and writing to it. Always set them explicitly rather than relying on system defaults.
val httpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.authenticator(TokenAuthenticator(tokenService))
.if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()connectTimeout is how long to wait for the TCP handshake. readTimeout is how long to wait between bytes arriving from the server. writeTimeout is how long to wait when writing your request body to the socket. Ten seconds to connect and thirty seconds to read is a reasonable starting point for mobile apps; adjust based on your API's actual performance and your tolerance for user-facing delays.
One pitfall: don't set timeouts inside an interceptor if you can avoid it—set them at the client builder level. Timeouts in interceptors apply only to that request phase, not the whole operation. Setting them globally ensures consistency across all requests.
Common mistakes
- Forgetting to register interceptors in the right place. Interceptors added to
OkHttpClientapply globally. If you're creating a separate client for each request or creating multiple clients in different parts of your app, you'll end up with some requests that lack your auth headers or logging. - Mixing application and network interceptors without understanding the difference. Application interceptors see your code's original request; network interceptors see what OkHttp is actually sending over the wire, including transparent redirects and compression. Use application interceptors for adding headers; use network interceptors for deep debugging.
- Logging at BODY level in production. Sensitive data—PII, tokens, payment info—can end up in logs. Even if you're not actively reading those logs, they can be captured by crash reporting services or by device owners with adb access. Always condition logging on
BuildConfig.DEBUG. - An authenticator that doesn't handle its own refresh endpoint. If your
TokenAuthenticatorcalls a refresh endpoint that itself requires the access token, and that token is expired, you'll recurse until OkHttp gives up. The refresh endpoint should use a refresh token or be unauthenticated. - Setting very short timeouts without understanding the impact. A five-second timeout might work fine on wifi but fail consistently on 3G. Test your timeout values against realistic network conditions and adjust based on user feedback and analytics.
Key takeaways
- Use application interceptors to add headers, auth tokens, and logging; they run before OkHttp processes the request internally.
- Use
Authenticatorto transparently refresh expired tokens and retry requests, not an interceptor, to avoid infinite loops. - Condition logging interceptors on
BuildConfig.DEBUGto avoid exposing sensitive data in production logs. - Set explicit timeouts at the client builder level for connection, read, and write operations; don't rely on system defaults.
- Keep token storage and refresh logic in a separate service that interceptors and authenticators can call; let them stay focused on request manipulation.






Comments
Loading comments…