Flutter: Handling JWT Token Refresh with Dio Interceptors
1. "Do I have to login AGAIN?"
The fastest way to lose users is forcing them to log in every 30 minutes. Security demands short-lived Access Tokens, but UX demands persistent sessions.
Our goal is "Silent Token Refresh": Updating the token behind the scenes without the user noticing.
2. Access Token vs Refresh Token
- Access Token: Short life (30m). Used for Authorization headers.
- Refresh Token: Long life (2w). Used ONLY to request a new Access Token. Stored securely.
The Flow:
- User likes a post.
- Server: "401 Unauthorized (Token Expired)"
- App (Interceptor): "Hold on."
- App: Sends Refresh Token to server.
- Server: "Here is a new token."
- App: Retries the 'Like' request with new token.
- User: Sees the heart turn red instantly.
3. Implementing Dio Interceptor
Using dio package, we can intercept errors globally.
Use QueuedInterceptorsWrapper to handle concurrent requests automatically.
dio.interceptors.add(
QueuedInterceptorsWrapper(
onError: (DioException err, handler) async {
// 1. Check if error is 401 Unauthorized
if (err.response?.statusCode == 401) {
// 2. Prevent infinite loop
// (if refresh endpoint itself returns 401, we are doomed)
if (err.requestOptions.path.contains('/refresh')) {
return handler.next(err);
}
try {
// 3. Get Refresh Token from Secure Storage
final refreshToken = await storage.read(key: 'refreshToken');
// 4. Request New Token (Use a separate Dio instance to avoid loop)
final response = await Dio().post(
'https://api.com/refresh',
data: {'token': refreshToken}
);
final newAccessToken = response.data['accessToken'];
await storage.write(key: 'accessToken', value: newAccessToken);
// 5. Clone the failed request and update header
final options = err.requestOptions;
options.headers['Authorization'] = 'Bearer $newAccessToken';
// 6. Retry
final retryResponse = await dio.fetch(options);
// 7. Resolve the original request with new response
return handler.resolve(retryResponse);
} catch (e) {
// If refresh fails, force logout
await _forceLogout();
return handler.next(err);
}
}
return handler.next(err);
},
),
);
4. The Concurrency "Race Condition"
Imagine the homepage fires 5 API calls simultaneously.
All 5 fail with 401.
Without queueing, your app might send 5 Refresh Requests.
The server might invalidate the refresh token after the first use, causing the other 4 to fail with Invalid Token.
Solution: Locking
QueuedInterceptorsWrapper handles this. When the first 401 is hit, it Locks the interceptor chain.
Subsequent requests are paused (Queued) until handler.resolve() is called.
Once the token is refreshed, the queue is released, and all pending requests are retried with the new token.
5. Security: Refresh Token Rotation
For banking-level security, use Rotation. Every time you use a Refresh Token, the server issues a new Refresh Token and invalidates the old one.
If a hacker steals your Refresh Token:
- They try to use it.
- Server sees it's already "used" (because the real user refreshed efficiently).
- Server detects "Token Reuse".
- Server invalidates the entire token family, forcing the user to log out (which saves the account).
FAQ
Q: Infinite 401 Loop?
A: Check if your /refresh endpoint itself requires an Access Token. It should be public or require only a Refresh Token. Also, ensure you are NOT using the intercepted Dio instance inside the interceptor.
Q: How to handle multiple concurrent requests?
A: As mentioned, use QueuedInterceptorsWrapper. It automatically queues simultaneous requests when the lock is active.
Q: What if Refresh Token expires?
A: Then the user must log out. There is no other way. Redirect them to the Login screen and clear secure storage.
6. Security Checklist: Are you really safe?
Implementing the interceptor is only half the battle. Verify these checkpoints:
- Secure Storage: Are you using
flutter_secure_storagewithencryptedSharedPreferencesenabled on Android? PlainSharedPreferencesis plain text. - HTTPS: Are all your endpoints SSL encrypted? Man-in-the-middle attacks can steal tokens in transit.
- Log Hygiene: Are you accidentally printing tokens to the console (
print(token))? Release builds should utilizereleasemode log stripping or a logger that sanitizes secrets. - Timeout: Does your refresh request have a timeout? It shouldn't hang forever. Set a 10s timeout to avoid bad UX.
- Scope: Does your Access Token have the minimum required scope? Don't give
adminprivileges to a user token.
7. Deep Dive Glossary
1. JWT (JSON Web Token)
A compact, URL-safe means of representing claims to be transferred between two parties. It consists of three parts: Header, Payload, and Signature. It is stateless, meaning the server doesn't need to store session data.
2. Bearer Token
The predominant type of Access Token used with OAuth 2.0. "Bearer" means "Give access to the bearer of this token." If you lose it, anyone who finds it can use it (like cash). This is why keeping it short-lived is crucial.
3. XSS (Cross-Site Scripting)
A vulnerability where an attacker injects malicious scripts into a trusted website. If you store tokens in localStorage or sessionStorage, XSS attacks can easily steal them. Ideally, tokens should be stored in HttpOnly cookies or memory (with Refresh Tokens in Secure Storage).
4. CSRF (Cross-Site Request Forgery)
An attack that forces an end user to execute unwanted actions on a web application in which they are currently authenticated. HttpOnly cookies are vulnerable to CSRF, but SameSite=Strict attribute helps mitigate this.
5. Refresh Token Rotation
A security measure where a new Refresh Token is issued every time the old one is used. This detects token theft: if an old refresh token is reused, the server knows it's been stolen and invalidates the entire token family.
6. Silent Refresh
The process of refreshing an Access Token without user interaction. In web apps, this is often done via a hidden iframe using cookies. In mobile apps, it's done via background API calls using the stored Refresh Token.
8. Best Practices for UX
When a token expires and refresh fails (e.g., user changed password on another device), the "Force Logout" experience is critical.
- Don't just crash: Redirect to the Login screen cleanly.
- Show a Toast: "Session expired. Please log in again."
- Preserve State: If possible, remember their navigation path so they return to the same screen after re-login.
- Clear Data: Ensure you wipe
secure_storageand local database to prevent data leaks.
9. Summary
- Intercept 401: Catch expiration errors globally.
- Silent Refresh: Call
/refreshendpoint transparently. - Retry: Re-send the original request with the new token using
dio.fetch. - Queueing: Ensure only one refresh request happens at a time.
- Storage: Never store tokens in SharedPreferences. Use
flutter_secure_storage.
A seamless auth experience is invisible. It just works.