OkHttpNetworkingRetrofitSecurityAndroid

OkHttp Interceptor Android Guide: Auth, Logging & Certificate Pinning

OkHttp interceptors are one of the most powerful and most misunderstood features in Android networking. This guide covers every interceptor pattern you need — from adding auth headers to refreshing tokens automatically to pinning certificates — with complete, production-ready Kotlin code.

·~11 min read

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.

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

kotlin
// 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()
Security note: Never log the Authorization header value in a release build. Even with redactHeader(), double-check your crash reporting SDK (Crashlytics, Sentry) is not capturing OkHttp logs from debug builds in production crash reports.

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.

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

kotlin
// 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.
Warning: Do not use certificate pinning in debug builds if you use a proxy like Charles or mitmproxy for network inspection — pinning will block all intercepted connections. Use 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.

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

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