Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 030 / 100

OkHttp interceptors: a practical Android & Kotlin guide

Add headers, logging and auth.

Expona Admin6 min read

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:

kotlin
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):

kotlin
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:

kotlin
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:

kotlin
dependencies {
  implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}

Then conditionally attach it:

kotlin
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.

kotlin
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:

kotlin
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.

kotlin
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 OkHttpClient apply 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 TokenAuthenticator calls 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 Authenticator to transparently refresh expired tokens and retry requests, not an interceptor, to avoid infinite loops.
  • Condition logging interceptors on BuildConfig.DEBUG to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles