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
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 ------------|
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
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
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
}
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()
}
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)
}
}
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)
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
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
401responses 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
}
}
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
Use a Mutex, synchronized state, or a dedicated single-flight coordinator. All waiting callers should receive the same refresh result.
A robust TokenManager should:
- Acquire the refresh lock.
- Re-check whether another request already updated the access token.
- Read the latest refresh token.
- Call the refresh endpoint through a separate client.
- Validate the response.
- Persist access and refresh tokens atomically.
- Update the in-memory token snapshot.
- Release waiting requests.
- 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)
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
R1is 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
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
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
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?
- Send the refresh request with the currently stored refresh token.
- Read the backend response.
- If a new refresh token is returned, save it together with the new access token atomically.
- If no new refresh token is returned, retain the current refresh token only if the API contract says it remains valid.
- Use server-provided expiry metadata when available; do not calculate a new refresh-token expiry by guessing.
- 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
}
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
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.
- Revoke the server session when possible.
- Clear local access and refresh tokens.
- Clear user-specific caches and sensitive local data as appropriate.
- Reset in-memory authentication state.
- Navigate to login.
- 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
401without 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.
- [ ]
401and403are 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
- OAuth 2.0 Security Best Current Practice β RFC 9700
- OAuth 2.0 β RFC 6749
- Android Keystore system
- OkHttp β Authenticators
- OWASP MASVS β Storage
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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.