1. What is an OkHttp interceptor?
An OkHttp interceptor is a function that sits in the call chain between your application code and the actual HTTP request. Every request passes through all registered interceptors before being sent to the server, and every response passes through them again on the way back. This gives you a clean, composable place to add cross-cutting concerns — authentication, logging, tracing, retry logic — without polluting your Repository or ViewModel code.
The interceptor pattern follows the Chain of Responsibility design: each interceptor calls chain.proceed(request) to pass control to the next interceptor in the chain. You can inspect and modify the request before proceed, and inspect and modify the response after it returns. You can also short-circuit the chain entirely by returning a synthetic response without calling proceed — useful for caching or mocking in tests.
2. Application vs Network interceptors
OkHttp has two interceptor lists with importantly different positions in the call chain:
Application Interceptors
Added via addInterceptor()
- +Always called exactly once, even for cached responses
- +See the original application URL (before redirects)
- +See the final response body even if served from cache
- −Cannot observe intermediate redirects or auth challenges
Use for: adding auth headers, logging business-level events, tracing
Network Interceptors
Added via addNetworkInterceptor()
- +See every network transmission, including redirects
- +See raw bytes, compressed headers, actual network URL
- −Not called for cached responses
- −May be called multiple times for retries
Use for: network-level logging, header compression inspection, connection monitoring
Rule of thumb: add auth headers in an Application interceptor so the header is present on every logical request including cached-response paths. Add raw wire logging in a Network interceptor to see the real bytes going over the wire.
3. Auth Header Interceptor
The most common interceptor pattern is injecting a Bearer token into every outgoing request. The token comes from a TokenProvider which wraps EncryptedSharedPreferences — never from a static field or companion object.
// TokenProvider.kt
class TokenProvider(private val prefs: EncryptedSharedPreferences) {
fun getAccessToken(): String? = prefs.getString("access_token", null)
fun getRefreshToken(): String? = prefs.getString("refresh_token", null)
fun saveTokens(accessToken: String, refreshToken: String) {
prefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.apply()
}
fun clearTokens() {
prefs.edit().clear().apply()
}
}
// AuthInterceptor.kt
class AuthInterceptor(private val tokenProvider: TokenProvider) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider.getAccessToken()
// Build a new request with the auth header added
val authenticatedRequest = if (token != null) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(authenticatedRequest)
}
}
// Wire it up with Retrofit
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider)) // Application interceptor
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()Note that this interceptor only adds the header — it does not handle token expiry. That is the job of the Authenticator covered in section 5.
4. Logging Interceptor
OkHttp ships with HttpLoggingInterceptor from the com.squareup.okhttp3:logging-interceptor artifact. Always use NONE in release builds and gate verbose logging behind BuildConfig.DEBUG.
// LoggingInterceptorFactory.kt
object LoggingInterceptorFactory {
fun create(): HttpLoggingInterceptor {
val logger = HttpLoggingInterceptor.Logger { message ->
// Route to Timber in debug; no-op in release
if (BuildConfig.DEBUG) {
Timber.tag("OkHttp").d(message)
}
}
return HttpLoggingInterceptor(logger).apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY // Log everything in debug
} else {
HttpLoggingInterceptor.Level.NONE // Never log in release
}
// Redact sensitive headers from logs
redactHeader("Authorization")
redactHeader("Cookie")
redactHeader("Set-Cookie")
}
}
}
// Wire it up — add AFTER auth interceptor so the Authorization
// header is visible in debug logs, but after redactHeader() masks it
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.addInterceptor(LoggingInterceptorFactory.create())
.build()5. The Authenticator — Token Refresh on 401
The Authenticator interface is separate from interceptors and is invoked by OkHttp specifically when a 401 Unauthorized response is received. It gets one chance to refresh the token and replay the original request. If it returns null, OkHttp propagates the 401 to the caller.
The critical detail: use @Synchronized on the refresh call to prevent multiple concurrent requests from all triggering a token refresh simultaneously (the "thundering herd" problem). After refreshing, check if the token has already been refreshed by another thread before making the network call.
// TokenAuthenticator.kt
class TokenAuthenticator(
private val tokenProvider: TokenProvider,
private val authApi: AuthApi, // A separate OkHttpClient with NO authenticator
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// If we already retried and still got a 401, give up to avoid loops
if (response.request.header("X-Retry-Auth") != null) {
return null
}
val newToken = refreshToken() ?: return null // null = log out the user
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.header("X-Retry-Auth", "true") // Mark as retried
.build()
}
@Synchronized
private fun refreshToken(): String? {
// Check if another thread already refreshed — if so, use the new token
val currentToken = tokenProvider.getAccessToken()
val refreshToken = tokenProvider.getRefreshToken() ?: return null
return try {
// Make a synchronous (blocking) call — we are already on an OkHttp thread
val refreshResponse = authApi.refreshTokenSync(
RefreshRequest(refreshToken = refreshToken)
).execute()
if (refreshResponse.isSuccessful) {
val body = refreshResponse.body()!!
tokenProvider.saveTokens(
accessToken = body.accessToken,
refreshToken = body.refreshToken
)
body.accessToken
} else {
// Refresh failed — clear tokens to trigger logout
tokenProvider.clearTokens()
null
}
} catch (e: IOException) {
null // Network error — let the original request fail
}
}
}
// IMPORTANT: The AuthApi used inside Authenticator must use a
// separate OkHttpClient that does NOT have the Authenticator attached,
// otherwise a 401 on the /refresh endpoint would cause infinite recursion.
val authOkHttpClient = OkHttpClient.Builder()
.build()
val authApi = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(authOkHttpClient)
.build()
.create(AuthApi::class.java)
val mainOkHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.authenticator(TokenAuthenticator(tokenProvider, authApi))
.build()6. Certificate Pinning with CertificatePinner
Certificate pinning prevents man-in-the-middle attacks by verifying the server's certificate against a known good fingerprint. OkHttp's CertificatePinner makes this straightforward. Pin the SHA-256 fingerprint of the public key (not the certificate itself, so the pin survives certificate renewal).
Always include at least two pins: the current certificate and a backup (either the intermediate CA or a pre-generated backup leaf key). Without a backup pin, a certificate rotation will break your app for all users until a new release is distributed.
// CertificatePinningFactory.kt
// To get the SHA-256 pin for your domain:
// Run: openssl s_client -connect api.example.com:443 | openssl x509 -pubkey -noout |
// openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
// Or use OkHttp's included CertificatePinner.pin() method in debug to log pins.
fun createCertificatePinner(): CertificatePinner {
return CertificatePinner.Builder()
// Primary pin — current leaf certificate public key
.add(
"api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
)
// Backup pin — intermediate CA (pin this to survive leaf rotation)
.add(
"api.example.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
)
// Also pin for staging/alternate hostnames as needed
.add(
"staging.api.example.com",
"sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="
)
.build()
}
val okHttpClient = OkHttpClient.Builder()
.certificatePinner(createCertificatePinner())
.addInterceptor(AuthInterceptor(tokenProvider))
.authenticator(TokenAuthenticator(tokenProvider, authApi))
.build()
// In debug builds, you can discover the pins by catching the exception:
// javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
// Peer certificate chain:
// sha256/AAAA...: CN=api.example.com ← this is your pin
// sha256/BBBB...: CN=DigiCert TLS RSA SHA256 2020 CA1 ← intermediate CA
// OkHttp logs the pins to help you set them up correctly.BuildConfig.DEBUG to conditionally apply the CertificatePinner.7. W3C Traceparent Header Injection
Distributed tracing allows you to correlate a user-facing error in your app with the exact backend log entry that caused it. The W3C Trace Context spec defines the traceparent header format: 00-{traceId}-{spanId}-{flags}. Inject this in an Application interceptor so every request carries a trace ID that your backend observability stack (Jaeger, Honeycomb, Datadog) can stitch together.
// TracingInterceptor.kt
class TracingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val traceId = generateTraceId() // 32 hex chars (128-bit)
val spanId = generateSpanId() // 16 hex chars (64-bit)
val traceFlags = "01" // "01" = sampled
val traceparent = "00-$traceId-$spanId-$traceFlags"
val request = chain.request().newBuilder()
.header("traceparent", traceparent)
.header("X-Request-Id", spanId) // Some backends use this header
.build()
val response = chain.proceed(request)
// Log the trace ID alongside any errors for easy correlation
if (!response.isSuccessful) {
Timber.w("Request failed: HTTP ${response.code}, traceId=$traceId, " +
"url=${request.url}")
}
return response
}
private fun generateTraceId(): String {
return buildString {
repeat(32) { append(HEX_CHARS.random()) }
}
}
private fun generateSpanId(): String {
return buildString {
repeat(16) { append(HEX_CHARS.random()) }
}
}
companion object {
private val HEX_CHARS = ('0'..'9') + ('a'..'f')
}
}
// Alternatively, use the OpenTelemetry Android SDK for full auto-instrumentation:
// implementation("io.opentelemetry.android:android-agent:0.6.0-alpha")8. Testing Interceptors with MockWebServer
OkHttp's MockWebServer runs a real HTTP server in your test process, allowing you to verify exactly what headers your interceptors send without mocking OkHttp itself. This gives you much higher confidence than unit tests with fakes.
// AuthInterceptorTest.kt
class AuthInterceptorTest {
private val mockWebServer = MockWebServer()
private val tokenProvider = FakeTokenProvider()
@Before
fun setUp() {
mockWebServer.start()
}
@After
fun tearDown() {
mockWebServer.shutdown()
}
@Test
fun `auth interceptor adds Bearer token to request`() {
// Arrange
tokenProvider.setToken("test-access-token-123")
mockWebServer.enqueue(MockResponse().setResponseCode(200))
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.build()
// Act
client.newCall(
Request.Builder()
.url(mockWebServer.url("/api/data"))
.build()
).execute()
// Assert
val recordedRequest = mockWebServer.takeRequest()
assertEquals(
"Bearer test-access-token-123",
recordedRequest.getHeader("Authorization")
)
}
@Test
fun `auth interceptor does not add header when token is null`() {
tokenProvider.setToken(null)
mockWebServer.enqueue(MockResponse().setResponseCode(200))
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.build()
client.newCall(
Request.Builder()
.url(mockWebServer.url("/api/data"))
.build()
).execute()
val recordedRequest = mockWebServer.takeRequest()
assertNull(recordedRequest.getHeader("Authorization"))
}
@Test
fun `authenticator refreshes token on 401 and retries request`() {
// First response: 401 to trigger refresh
mockWebServer.enqueue(MockResponse().setResponseCode(401))
// Token refresh endpoint response
mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"accessToken":"new-token","refreshToken":"new-refresh"}""")
)
// Retried original request
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("OK"))
val refreshApi = createRefreshApi(mockWebServer.url("/").toString())
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider))
.authenticator(TokenAuthenticator(tokenProvider, refreshApi))
.build()
val response = client.newCall(
Request.Builder()
.url(mockWebServer.url("/api/data"))
.build()
).execute()
assertEquals(200, response.code)
// Three requests made: original, refresh, retry
assertEquals(3, mockWebServer.requestCount)
// Final request should have the new token
val finalRequest = mockWebServer.takeRequest() // original
mockWebServer.takeRequest() // refresh
val retryRequest = mockWebServer.takeRequest() // retry
assertEquals("Bearer new-token", retryRequest.getHeader("Authorization"))
}
}9. Common Mistakes
Putting token refresh logic in the AuthInterceptor
Interceptors are not the right place for token refresh. Use the Authenticator. Interceptors run on every request; the Authenticator only runs on 401 responses. Mixing them causes subtle bugs where refresh logic runs on non-auth failures.
Using the same OkHttpClient for /refresh inside the Authenticator
If your Authenticator uses the same OkHttpClient (which has the Authenticator attached), a 401 from the /refresh endpoint will recursively call the Authenticator — infinite loop, StackOverflowError. Always use a separate bare OkHttpClient for auth API calls.
Not protecting token refresh with @Synchronized
If 5 concurrent requests all get a 401, they will all call refreshToken() simultaneously. Without synchronization, you will fire 5 refresh calls, get 5 new tokens (each invalidating the previous), and cause a cascade of 401s. Use @Synchronized or a Mutex.
Logging the Authorization header value in release builds
Bearer tokens are credentials. Logging them — even to Logcat — is a security risk on rooted devices. Use HttpLoggingInterceptor.redactHeader("Authorization") and ensure NONE level in release.
Pinning the leaf certificate instead of the public key
Certificate pinning pins should be computed from the public key (sha256/), not the certificate bytes. The public key stays the same across certificate renewals (if you use the same key pair), so key pinning survives cert rotation. Certificate pinning breaks on every renewal.
Adding network interceptors for auth headers
Auth headers should be added in Application interceptors, not Network interceptors. Network interceptors are called per network transmission, including redirects — your auth header could be sent to redirect targets. Application interceptors are called once per logical call.
Practice networking in the interactive course
The Networking & Real-Time chapter covers OkHttp, WebSocket, SSE, certificate pinning, and more — with quizzes and code challenges.