DEV Community

Cover image for Access Token vs Refresh Token in Android: Secure Authentication with Kotlin
Padmakar Garg
Padmakar Garg

Posted on

Access Token vs Refresh Token in Android: Secure Authentication with Kotlin

A user logs in to an Android application. The backend returns an access token and a refresh token. The app stores them, attaches the access token to API requests, and renews it when it expires—without asking the user to log in again.

That flow looks simple, but production apps must answer important questions:

  • What is the difference between access and refresh tokens?
  • Where should tokens be stored on Android?
  • How should an expired access token be refreshed?
  • What if multiple requests fail at the same time?
  • How does refresh-token rotation work?
  • What should happen during logout or process death?

This article explains the concepts and a practical Kotlin architecture using MVVM + Clean Architecture, Retrofit/OkHttp, a TokenRepository, an interceptor, and an authenticator.

Examples are illustrative. Adapt them to your backend contract and test them in an authorized environment. The backend—not the Android client—is the authority for authentication and authorization.

1. Access token vs refresh token

An access token is a credential sent to protected resource/API endpoints. A refresh token is a credential used to obtain a new access token when the current one expires or becomes invalid.

Access token Refresh token
Used to call protected APIs Used only at the refresh endpoint
Usually short-lived Usually longer-lived, according to server policy
Sent with API requests Never sent with every API request
Exposure can enable API access Exposure can enable session renewal
Limited scope/audience is desirable Requires strict protection, rotation and revocation

Tokens may be JWTs or opaque strings. Decoding a JWT on Android does not validate it; the resource server must validate signature, expiry, issuer, audience, and applicable claims.

2. Authentication and refresh flow

Android App                              Backend
    |                                      |
    |------ Login credentials / OTP ------>|
    |<----- Access + Refresh tokens -------|
    |                                      |
    | Store tokens securely                |
    |                                      |
    |------ API + Access token ----------->|
    |<----- Protected response ------------|
    |                                      |
    | Access token expires                 |
    |                                      |
    |------ Refresh token ---------------->|
    |<----- New access token --------------|
    |<----- (optional new refresh token) --|
    |                                      |
    |------ Retry original API ----------->|
    |<----- Protected response ------------|
Enter fullscreen mode Exit fullscreen mode

The backend may return only a new access token or rotate both tokens. Android must follow the API contract exactly.

3. Recommended Android architecture

Keep token handling out of Activities and Composables.

Presentation: Compose / ViewModel
              |
              v
Domain: UseCases
              |
              v
Data: AuthRepository / TokenRepository
              |
              v
Network: Retrofit + OkHttp
  ├── AccessTokenInterceptor
  ├── TokenAuthenticator
  └── RefreshApi (separate client)
              |
              v
Backend: Login / Refresh / Logout / APIs
Enter fullscreen mode Exit fullscreen mode

A refresh request can run without a visible Activity. It still needs a running app process and network access; Android does not guarantee that your process will remain alive indefinitely in the background or after being killed.

3A. Real-world timing example: token lifetime and API behavior

Let's use an example backend policy (these are sample values, not universal defaults):

  • Access token lifetime: 15 minutes
  • Refresh token lifetime: 30 days, subject to revocation/rotation
  • Refresh request timeout: 10 seconds
  • Refresh retry: at most once, and only for a transient network failure if the backend contract allows it
  • API retry after refresh: one retry maximum

Timeline: what happens after login?

10:00:00  User logs in
          Backend returns:
          Access token A1 (expires at 10:15)
          Refresh token R1

10:00:02  GET /profile  -> Authorization: Bearer A1
          API returns 200 OK

10:07:00  POST /orders  -> Authorization: Bearer A1
          API returns 201 Created

10:15:00  A1 expires (server-side)

10:15:03  GET /profile with A1
          API returns 401 + TOKEN_EXPIRED

10:15:03  Android sends POST /auth/refresh with R1
          Backend validates R1

10:15:04  Backend returns A2 + R2 (rotation example)
          Android atomically stores A2 and R2

10:15:04  Android retries GET /profile with A2
          API returns 200 OK
Enter fullscreen mode Exit fullscreen mode

The user generally sees no login screen during a successful refresh. If refresh fails because R1 is revoked or expired, clear the session and require login. If the failure is merely a temporary network outage, preserve the session state safely and show/retry according to your app's UX policy—do not treat every timeout as token revocation.

When should Android refresh proactively?

If the access token expiry is known and trustworthy enough for scheduling, the client can refresh shortly before expiry. For example, with a 15-minute token, a client might attempt refresh around 14 minutes after issuance, leaving a small buffer. This is only an optimization; server-side expiry remains authoritative.

Avoid hardcoding “refresh every 14 minutes” for every app. Prefer the backend's expires_in value, account for clock skew, and refresh only when needed. If the app is offline at that moment, it may not be able to refresh; the next protected API call must still handle an expired token correctly.

expires_in example

A login response might look like this:

{
  "access_token": "ACCESS_TOKEN_VALUE",
  "refresh_token": "REFRESH_TOKEN_VALUE",
  "token_type": "Bearer",
  "expires_in": 900
}
Enter fullscreen mode Exit fullscreen mode

expires_in: 900 means the access token is valid for 900 seconds (15 minutes), according to the backend's policy. Treat the token strings as secrets; never paste real production tokens into logs, screenshots, articles, or code examples.

4. Secure token storage

Avoid storing tokens in plain SharedPreferences, public files, logs, analytics, clipboard, source code, or BuildConfig.

Use a reviewed secure-storage implementation or a carefully designed encryption layer whose key is protected by Android Keystore. Keystore protects key material; it does not automatically encrypt arbitrary strings.

Define a storage abstraction:

data class TokenPair(
    val accessToken: String,
    val refreshToken: String
)

interface TokenRepository {
    suspend fun getAccessToken(): String?
    suspend fun getRefreshToken(): String?
    suspend fun saveTokens(tokens: TokenPair)
    suspend fun clearTokens()
}
Enter fullscreen mode Exit fullscreen mode

Keep access centralized. Never log token values. Consider backup/device-transfer behavior, logout, account switching, and atomic persistence of a rotated token pair.

5. Attach the access token to API requests

An OkHttp interceptor can add the access token to requests sent to your trusted API host:

class AccessTokenInterceptor(
    private val tokenProvider: TokenProvider
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()

        // Only attach credentials to your trusted API host.
        if (request.url.host != "api.example.com") {
            return chain.proceed(request)
        }

        val token = tokenProvider.currentAccessToken()

        val authenticated = request.newBuilder().apply {
            if (!token.isNullOrBlank()) {
                header("Authorization", "Bearer $token")
            }
        }.build()

        return chain.proceed(authenticated)
    }
}
Enter fullscreen mode Exit fullscreen mode

This example uses a synchronous in-memory TokenProvider snapshot to avoid blocking the interceptor on disk I/O. Update that snapshot whenever tokens are saved or cleared. Do not attach tokens to arbitrary hosts, and avoid overwriting an explicitly supplied Authorization header without a deliberate policy.

6. Refresh an expired access token

Two common approaches:

Proactive refresh: renew shortly before a known expiry. This can reduce failed requests, but client clocks may be inaccurate and token claims do not replace server validation.

Reactive refresh: an API returns 401; the network layer refreshes and retries once.

Do not refresh on every 401 blindly. A 401 may mean invalid credentials, revoked session, or another authentication error. Prefer a backend error code that explicitly identifies an expired access token. Never create an infinite retry loop.

6A. API performance: normal request vs refresh request

Token refresh should be an exceptional authentication operation, not an extra API call before every request.

Normal API request

App -> Interceptor reads current token snapshot
    -> Adds Authorization header
    -> API request
    -> Backend validates access token
    -> Backend authorizes endpoint
    -> Response (200 / 4xx / 5xx)
Enter fullscreen mode Exit fullscreen mode

Expected overhead on Android is just reading an in-memory token and adding a header. Do not call /auth/refresh for every API request.

Expired-token request

App -> API request with expired A1
    -> Backend returns 401 TOKEN_EXPIRED
    -> Authenticator / coordinator starts refresh
    -> POST /auth/refresh with R1
    -> Backend returns A2 (+ R2)
    -> TokenRepository saves token pair
    -> Original request retries once with A2
    -> Backend returns final response
Enter fullscreen mode Exit fullscreen mode

The refresh flow adds one network round trip only when renewal is required. If the refresh endpoint takes 300 ms and the original API takes 250 ms, a successful expired-token request may take roughly 550 ms plus processing and retry overhead. This is an illustrative calculation, not a performance guarantee; real latency depends on network, server, and queueing.

Avoid unnecessary refresh traffic

  • Do not refresh before every Retrofit call.
  • Do not refresh merely because a request returned 403 Forbidden; that normally means permission is insufficient, not that the token expired.
  • Do not refresh for arbitrary 401 responses unless the backend identifies an expired access token.
  • Do not retry a request repeatedly after refresh.
  • For a burst of expired-token requests, share one refresh result (single-flight).
  • Keep refresh endpoint and protected API clients separate to avoid recursive refresh.
  • Use bounded connect/read/call timeouts and measure latency with safe telemetry (never token values).

Example response handling

Result Recommended client behavior
200–299 Return success
401 TOKEN_EXPIRED Single refresh attempt, then retry original request once
401 INVALID_TOKEN / revoked session Clear session and require login
403 FORBIDDEN Do not refresh; show authorization error
429 Respect Retry-After and API policy; don't refresh
5xx Apply bounded retry only when safe/idempotent
Network timeout during refresh Treat as unknown outcome; don't assume refresh token is invalid
Refresh token expired/revoked Clear credentials and require login

7. OkHttp Authenticator: retry after authentication failure

OkHttp's Authenticator is designed to respond to authentication challenges. A simplified example:

class TokenAuthenticator(
    private val tokenManager: TokenManager
) : Authenticator {

    override fun authenticate(
        route: Route?,
        response: Response
    ): Request? {
        if (responseCount(response) >= 2) return null

        val token = tokenManager.refreshAccessTokenBlocking()
            ?: return null

        return response.request.newBuilder()
            .header("Authorization", "Bearer $token")
            .build()
    }

    private fun responseCount(response: Response): Int {
        var count = 1
        var prior = response.priorResponse
        while (prior != null) {
            count++
            prior = prior.priorResponse
        }
        return count
    }
}
Enter fullscreen mode Exit fullscreen mode

This demonstrates the retry boundary, not a complete production TokenManager. The refresh operation must use a dedicated client, bounded timeouts, and concurrency control.

8. The critical issue: concurrent refresh requests

Suppose ten API requests use the same expired token and all receive 401. If each independently refreshes, the backend may receive ten refresh requests.

With refresh-token rotation, this can invalidate the old refresh token and cause the remaining requests to fail.

Use single-flight refresh

Request A ---- 401 ----\
Request B ---- 401 -----+--> Refresh Coordinator
Request C ---- 401 ----/          |
                                  v
                         One refresh request
                                  |
                    +-------------+-------------+
                    |                           |
                    v                           v
              Token pair updated          Refresh rejected
                    |                           |
                    v                           v
             Retry waiting calls        Clear session /
                                         require login
Enter fullscreen mode Exit fullscreen mode

Use a Mutex, synchronized state, or a dedicated single-flight coordinator. All waiting callers should receive the same refresh result.

A robust TokenManager should:

  1. Acquire the refresh lock.
  2. Re-check whether another request already updated the access token.
  3. Read the latest refresh token.
  4. Call the refresh endpoint through a separate client.
  5. Validate the response.
  6. Persist access and refresh tokens atomically.
  7. Update the in-memory token snapshot.
  8. Release waiting requests.
  9. Clear the session on unrecoverable refresh failure.

9. Refresh-token rotation

With rotation, every successful refresh may return a new refresh token, invalidating the old one according to backend policy. Rotation alone does not tell you whether expiry is sliding or fixed; see the 24-hour scenarios below.

Refresh Token R1
       |
       v
Backend validates R1
       |
       v
Access Token A2 + Refresh Token R2
       |
       v
Android atomically saves A2 + R2
       |
       v
R1 is no longer reusable (rotation policy)
Enter fullscreen mode Exit fullscreen mode

Never save only the access token if the backend also returned a new refresh token. Persist the pair atomically; partial writes can break the session if the app crashes.

Refresh tokens should have expiry, revocation, rotation, and reuse detection where supported. The backend needs a clear response to suspected token theft.

9A. Important scenario: refresh token expires in 24 hours

This is the question many implementations get wrong:

If my refresh token is valid for 24 hours and I call the refresh API at 23 hours 59 minutes, will the backend give me a new refresh token? Will its expiry become another 24 hours?

The answer depends entirely on the backend's refresh-token policy. A refresh request does not automatically extend a refresh token's lifetime. The backend must explicitly implement that behavior.

Assume this sample policy:

  • Refresh token R1 is issued at Day 1, 10:00 AM.
  • Its lifetime is 24 hours.
  • Its absolute expiry is Day 2, 10:00 AM.
  • The access token expires much earlier (for example, after 15 minutes).

Case A — Fixed expiry, no refresh-token rotation

The backend keeps the same refresh token and its original expiry:

Day 1, 10:00 AM  Login -> A1 + R1
                  R1 expires Day 2, 10:00 AM

Day 2, 09:59 AM  Refresh request with R1
                  Backend returns A2 only
                  R1 remains R1
                  R1 still expires at Day 2, 10:00 AM

Day 2, 10:01 AM  Refresh request with R1
                  Backend rejects it: refresh token expired
                  User must sign in again
Enter fullscreen mode Exit fullscreen mode

In this design, refreshing at 23 hours 59 minutes renews the access token, not the refresh token's expiry. The user has only one minute remaining before the refresh token expires.

Case B — Rotating refresh token with a sliding 24-hour expiry

The backend rotates the refresh token on every successful refresh and gives the replacement a new 24-hour lifetime:

Day 1, 10:00 AM  Login -> A1 + R1
                  R1 expires Day 2, 10:00 AM

Day 2, 09:59 AM  Refresh request with R1
                  Backend returns A2 + R2
                  R1 is invalidated
                  R2 expires Day 3, 09:59 AM

Day 3, 09:58 AM  Refresh request with R2
                  Backend returns A3 + R3
                  R2 is invalidated
                  R3 expires Day 4, 09:58 AM
Enter fullscreen mode Exit fullscreen mode

Here, refreshing at 23:59 does extend the session because the backend issues R2 with a fresh 24-hour expiry. This is often called a sliding/session-renewal policy. The exact behavior must be documented by the API.

Case C — Rotation, but with an absolute session expiry

A backend may rotate refresh tokens but enforce a maximum session age—for example, 7 days from the original login:

Day 1  Login -> R1; absolute session expiry = Day 8
Day 2  Refresh -> R2; R2 has a shorter remaining lifetime
Day 3  Refresh -> R3; expiry still cannot exceed Day 8
...
Day 8  Refresh rejected; re-authentication required
Enter fullscreen mode Exit fullscreen mode

Even if a new refresh token is returned on every refresh, the session cannot continue beyond the absolute expiry. Rotation and sliding expiry are separate policy decisions.

Compare the three policies

Backend policy New refresh token on refresh? Expiry behavior
Fixed refresh token Usually no Original expiry remains unchanged
Rotation + sliding expiry Yes Replacement token gets a new lifetime (e.g. 24 hours from now)
Rotation + absolute session limit Yes Replacement token cannot exceed the original maximum session deadline

Do not infer the policy from the token string. Confirm it from backend documentation or response fields such as refresh_token, refresh_expires_in, or an explicit session expiry. If the API does not return a new refresh token, Android must not invent one or assume the old token's expiry was extended.

What should Android do at 23 hours 59 minutes?

  1. Send the refresh request with the currently stored refresh token.
  2. Read the backend response.
  3. If a new refresh token is returned, save it together with the new access token atomically.
  4. If no new refresh token is returned, retain the current refresh token only if the API contract says it remains valid.
  5. Use server-provided expiry metadata when available; do not calculate a new refresh-token expiry by guessing.
  6. If refresh is rejected as expired/revoked, clear the local session and require login.

Example response for rotation:

{
  "access_token": "ACCESS_TOKEN_A2",
  "refresh_token": "REFRESH_TOKEN_R2",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_expires_in": 86400
}
Enter fullscreen mode Exit fullscreen mode

In this illustrative response, expires_in: 900 means the access token lifetime is 15 minutes, while refresh_expires_in: 86400 means the newly issued refresh token lifetime is 24 hours. These fields are backend-specific; OAuth does not require every server to return refresh_expires_in.

A security and reliability warning: refresh at the last minute

Do not design the Android app to wait until 23:59 of a 24-hour refresh-token lifetime. The request may fail due to poor connectivity, device clock differences, server processing time, or the token expiring before the backend receives it.

If the backend supports sliding renewal, refresh with a safety buffer (for example, when the app is active and the token has only a few minutes left), while still handling expiry reactively. The buffer is a product/backend choice—not a universal fixed interval. Android cannot refresh while offline, and background execution is not guaranteed to happen at an exact time.


10. Use a separate refresh client

Protected Retrofit client
  ├── AccessTokenInterceptor
  └── TokenAuthenticator
          |
          v
      TokenManager
       /       \
      v         v
TokenRepository RefreshApi
                  |
                  v
          Separate OkHttp client
Enter fullscreen mode Exit fullscreen mode

A separate client prevents refresh calls from recursively invoking the same authenticator. It also makes refresh timeouts and error handling explicit.

Do not retry refresh indefinitely. Do not automatically retry non-idempotent operations unless the backend supports idempotency and safe replay.

11. Logout and session expiration

Logout should not be just a UI navigation event.

  1. Revoke the server session when possible.
  2. Clear local access and refresh tokens.
  3. Clear user-specific caches and sensitive local data as appropriate.
  4. Reset in-memory authentication state.
  5. Navigate to login.
  6. Ensure pending requests cannot restore the logged-out session.

If the device is offline, clear local credentials immediately and handle server-side revocation according to your product policy. A temporary network error is not the same as an invalid refresh token; distinguish recoverable connectivity failures from revoked/expired sessions.

12. Common mistakes

  • Storing tokens in plain preferences.
  • Sending refresh tokens to every endpoint.
  • Putting tokens in query parameters.
  • Logging Authorization headers.
  • Refreshing on every 401 without checking its meaning.
  • Allowing multiple concurrent refresh calls.
  • Ignoring refresh-token rotation.
  • Using the same client for protected API calls and refresh calls.
  • Retrying the original request forever.
  • Treating client-side JWT decoding as authentication.
  • Assuming WorkManager keeps a session alive continuously.
  • Assuming encryption or obfuscation makes a stolen token harmless.

13. Production security checklist

  • [ ] Access tokens are short-lived according to backend policy.
  • [ ] Refresh tokens are rotated/revoked where supported.
  • [ ] Tokens use a reviewed secure-storage approach.
  • [ ] Authorization headers are restricted to trusted API hosts.
  • [ ] Refresh uses a dedicated client.
  • [ ] Refresh is single-flight and token updates are atomic.
  • [ ] Retry count is bounded.
  • [ ] 401 and 403 are handled distinctly.
  • [ ] Logout clears local state and revokes the server session when possible.
  • [ ] Tokens are excluded from logs and analytics.
  • [ ] Backend validates tokens and authorizes every protected operation.
  • [ ] Process death, offline state, token rotation, and account switching are tested.

Conclusion

Access tokens and refresh tokens serve different purposes. The access token calls protected APIs; the refresh token renews the session. Both are credentials and both require careful handling.

For Android, the key engineering decisions are secure storage, host-restricted token injection, a dedicated refresh client, concurrency-safe refresh, atomic token rotation, and reliable logout behavior. The backend remains responsible for token validation and authorization.

A refresh flow should be seamless during normal operation—but bounded, observable without exposing secrets, and capable of ending the session safely when renewal is no longer valid.

How are you implementing token refresh in your Android app: OkHttp Authenticator, an interceptor-based coordinator, or a custom network layer? Share your approach in the comments.

Official references


About the author

Padmakar Garg is an Android Developer with 8+ years of experience in Kotlin, Java, Jetpack Compose, Kotlin Multiplatform, scalable architecture, mobile security, and high-performance app development.

Top comments (0)