The single token problem
Here is a pattern that appears in a large number of production Flutter apps. The user logs in, the server returns a token, the app stores it, and every subsequent API request includes that token in the Authorization header. The token expires in 30 days. The user stays logged in for a month without friction. Everyone is happy.
Until the token is stolen.
It does not matter how it was stolen — a compromised device, a man-in-the-middle attack on an improperly pinned connection, a debug build that logged headers to a third-party crash reporting service. What matters is the consequence: the attacker now has full access to the user's account for up to 30 days. They can read data, modify state, impersonate the user. And there is no mechanism to limit the damage. The token is valid, the server accepts it, and the legitimate user has no idea anything is wrong.
You could make the token expire faster — say, every hour. But then your users are logging in 24 times a day, which is not a product anyone would ship. You need a way to have short-lived credentials for security and long-lived sessions for usability. That is exactly what the access/refresh pattern provides.
What a token actually is
Before implementing the pattern, it helps to understand the thing you are passing around. Most modern APIs use JWTs — JSON Web Tokens — and the name is more literal than it sounds.
A JWT is three pieces of data separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0Iiwicm9sZSI6InVzZXIiLCJpYXQiOjE3MTExMDAwMDAsImV4cCI6MTcxMTEwMDkwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cEach piece is base64-encoded. The first is the header — it declares the algorithm used for the signature (usually HS256 or RS256). The second is the payload — this is the interesting part. Decode it and you get a JSON object:
{
"userId": "1234",
"role": "user",
"iat": 1711100000,
"exp": 1711100900
}These fields are called claims. userId identifies who the token belongs to. role might determine what they can access. iat (issued at) is when the token was created. exp is when it expires. Both are Unix timestamps — seconds since 1 January 1970.
The third piece is the signature. The server takes the header and payload, concatenates them, and signs them using a secret key that only the server knows. When the server receives a token back from a client, it recomputes the signature using the same secret. If the signatures match, the payload has not been tampered with. If someone changes the userId from "1234" to "5678", the signature will no longer match, and the server rejects the token.
This is the critical property: JWTs are not encrypted. Anyone can decode and read the payload. But no one can modify it without the server's secret key. The signature proves authenticity, not confidentiality. Never put sensitive information (passwords, payment details, personal data) in a JWT payload.
When the server receives a request with a JWT, it does three things: verifies the signature is valid, checks that exp has not passed, and extracts the claims to identify and authorise the user. All of this happens without a database lookup — the token is self-contained. That is both the strength and the limitation of JWTs.
The access/refresh pattern
The pattern introduces two tokens instead of one, each with a different purpose and lifespan.
The access token is short-lived — typically 5 to 15 minutes. It is sent with every API request in the Authorization header. Because it expires quickly, a stolen access token gives an attacker a narrow window. Fifteen minutes instead of thirty days.
The refresh token is long-lived — days or weeks. It is stored securely on the device (see Post 1 on secure storage) and used for exactly one purpose: obtaining a new access token when the current one expires. It is never sent to regular API endpoints. It is only ever sent to a single dedicated refresh endpoint.
The login flow works like this:
- User submits credentials (email + password, OAuth token, etc.)
- Server validates credentials
- Server returns both an access token and a refresh token
- App stores both tokens in secure storage
- App uses the access token for all subsequent requests
The refresh flow kicks in when the access token expires:
- App makes a request with the access token
- Server responds with
401 Unauthorized(token expired) - App sends the refresh token to
/auth/refresh - Server validates the refresh token, issues a new access token (and often a new refresh token — more on this below)
- App stores the new tokens
- App retries the original failed request with the new access token
From the user's perspective, nothing happened. They never saw a login screen. From a security perspective, the window of vulnerability has been reduced from 30 days to 15 minutes.
The token model
Before building the interceptor, define the data structures. These are straightforward:
class AuthTokens {
final String accessToken;
final String refreshToken;
const AuthTokens({
required this.accessToken,
required this.refreshToken,
});
factory AuthTokens.fromJson(Map<String, dynamic> json) {
return AuthTokens(
accessToken: json['accessToken'] as String,
refreshToken: json['refreshToken'] as String,
);
}
}The AuthTokenRepository
This is the data layer component that handles reading and writing tokens to secure storage. If you read Post 1 in this series, you already have flutter_secure_storage set up with the correct platform options. The repository wraps it:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AuthTokenRepository {
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
final FlutterSecureStorage _storage;
AuthTokenRepository({FlutterSecureStorage? storage})
: _storage = storage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility:
KeychainAccessibility.first_unlock_this_device,
),
);
Future<AuthTokens?> getTokens() async {
final accessToken = await _storage.read(key: _accessTokenKey);
final refreshToken = await _storage.read(key: _refreshTokenKey);
if (accessToken == null || refreshToken == null) return null;
return AuthTokens(
accessToken: accessToken,
refreshToken: refreshToken,
);
}
Future<void> saveTokens(AuthTokens tokens) async {
await Future.wait([
_storage.write(key: _accessTokenKey, value: tokens.accessToken),
_storage.write(key: _refreshTokenKey, value: tokens.refreshToken),
]);
}
Future<void> clearTokens() async {
await _storage.deleteAll();
}
}A few things to note. The constructor accepts an optional FlutterSecureStorage instance, which makes the class testable — you can inject a mock in tests. The saveTokens method writes both tokens in parallel with Future.wait because neither depends on the other. And clearTokens removes everything, which is exactly what logout requires.
The Dio interceptor
This is where the pattern comes together. The interceptor does two things: it attaches the access token to every outgoing request, and it handles the refresh flow when a request fails with a 401.
Here is the basic version first, before we deal with race conditions:
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
final Dio _dio;
final AuthTokenRepository _tokenRepository;
final void Function() _onSessionExpired;
AuthInterceptor({
required Dio dio,
required AuthTokenRepository tokenRepository,
required void Function() onSessionExpired,
}) : _dio = dio,
_tokenRepository = tokenRepository,
_onSessionExpired = onSessionExpired;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final tokens = await _tokenRepository.getTokens();
if (tokens != null) {
options.headers['Authorization'] = 'Bearer ${tokens.accessToken}';
}
handler.next(options);
}
@override
Future<void> onError(
DioException error,
ErrorInterceptorHandler handler,
) async {
if (error.response?.statusCode != 401) {
return handler.next(error);
}
// Don't try to refresh if the refresh request itself failed
if (error.requestOptions.path == '/auth/refresh') {
_onSessionExpired();
return handler.next(error);
}
try {
final tokens = await _tokenRepository.getTokens();
if (tokens == null) {
_onSessionExpired();
return handler.next(error);
}
// Call the refresh endpoint
final response = await _dio.post(
'/auth/refresh',
data: {'refreshToken': tokens.refreshToken},
);
final newTokens = AuthTokens.fromJson(response.data);
await _tokenRepository.saveTokens(newTokens);
// Retry the original request with the new access token
final retryOptions = error.requestOptions;
retryOptions.headers['Authorization'] =
'Bearer ${newTokens.accessToken}';
final retryResponse = await _dio.fetch(retryOptions);
return handler.resolve(retryResponse);
} catch (e) {
_onSessionExpired();
return handler.next(error);
}
}
}The _onSessionExpired callback is how the interceptor communicates upward to your app's authentication state. When refresh fails, the app needs to navigate the user to the login screen. The interceptor does not do this directly — it has no knowledge of your routing or state management. It calls a function, and whatever manages your auth state (a BLoC, a Riverpod provider, a ValueNotifier) responds accordingly.
Notice the guard on the refresh endpoint path. Without it, a failed refresh request would trigger another refresh attempt, which would fail, which would trigger another, indefinitely. The interceptor must know not to retry its own refresh calls.
The race condition problem
The implementation above has a subtle but serious bug. Consider this scenario:
The access token expires. Five requests are in flight simultaneously — a profile fetch, a notifications check, a settings load, a message list, and a badge count. All five receive a 401 response at roughly the same time. All five enter the onError handler. All five attempt to refresh the token.
If your server uses one-time refresh tokens (and it should — more on this shortly), the first refresh succeeds. The other four send a refresh token that the server has already invalidated. They all fail. Four out of five requests throw errors even though the user's session is valid.
The fix is a lock that ensures only one refresh happens at a time, while the other requests wait for it to complete:
import 'dart:async';
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
final Dio _dio;
final AuthTokenRepository _tokenRepository;
final void Function() _onSessionExpired;
bool _isRefreshing = false;
Completer<AuthTokens?>? _refreshCompleter;
AuthInterceptor({
required Dio dio,
required AuthTokenRepository tokenRepository,
required void Function() onSessionExpired,
}) : _dio = dio,
_tokenRepository = tokenRepository,
_onSessionExpired = onSessionExpired;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final tokens = await _tokenRepository.getTokens();
if (tokens != null) {
options.headers['Authorization'] = 'Bearer ${tokens.accessToken}';
}
handler.next(options);
}
@override
Future<void> onError(
DioException error,
ErrorInterceptorHandler handler,
) async {
if (error.response?.statusCode != 401) {
return handler.next(error);
}
if (error.requestOptions.path == '/auth/refresh') {
_onSessionExpired();
return handler.next(error);
}
// If a refresh is already in progress, wait for it
if (_isRefreshing) {
final tokens = await _refreshCompleter?.future;
if (tokens != null) {
final retryOptions = error.requestOptions;
retryOptions.headers['Authorization'] =
'Bearer ${tokens.accessToken}';
final retryResponse = await _dio.fetch(retryOptions);
return handler.resolve(retryResponse);
}
return handler.next(error);
}
// This request is the first to detect the expired token
_isRefreshing = true;
_refreshCompleter = Completer<AuthTokens?>();
try {
final tokens = await _tokenRepository.getTokens();
if (tokens == null) {
_refreshCompleter?.complete(null);
_onSessionExpired();
return handler.next(error);
}
final response = await _dio.post(
'/auth/refresh',
data: {'refreshToken': tokens.refreshToken},
);
final newTokens = AuthTokens.fromJson(response.data);
await _tokenRepository.saveTokens(newTokens);
// Signal all waiting requests that refresh succeeded
_refreshCompleter?.complete(newTokens);
// Retry the original request
final retryOptions = error.requestOptions;
retryOptions.headers['Authorization'] =
'Bearer ${newTokens.accessToken}';
final retryResponse = await _dio.fetch(retryOptions);
return handler.resolve(retryResponse);
} catch (e) {
_refreshCompleter?.complete(null);
_onSessionExpired();
return handler.next(error);
} finally {
_isRefreshing = false;
_refreshCompleter = null;
}
}
}The mechanism is a Completer. When the first request detects a 401, it sets _isRefreshing = true and creates a new Completer. Every subsequent 401 that arrives while the refresh is in progress sees _isRefreshing == true, and instead of attempting its own refresh, it awaits _refreshCompleter.future. When the refresh finishes — success or failure — the completer resolves, and all waiting requests either retry with the new token or propagate the error.
This is the pattern you will find in most production Flutter applications that handle authentication correctly. It is not over-engineering. It is handling a scenario that will happen every time your access token has a short expiry and your app makes concurrent requests, which is most of the time.
Token rotation
When the server receives a valid refresh token, it has a choice: issue only a new access token, or issue both a new access token and a new refresh token.
Issuing both — and invalidating the old refresh token — is called token rotation. It is the more secure approach, and this is why.
Without rotation, a stolen refresh token remains valid for its entire lifespan (say, 30 days). The attacker can use it to generate new access tokens indefinitely, even if the legitimate user continues using the app normally. Neither the user nor the server knows anything is wrong.
With rotation, each refresh token is single-use. When the server issues a new refresh token, it marks the old one as consumed. If an attacker steals a refresh token and uses it before the legitimate user does, the legitimate user's next refresh attempt sends an already-consumed token. The server detects this — a consumed token being presented again is a strong signal of token theft. The server can then invalidate the entire token family (all tokens descended from the same login), forcing a re-login on all devices.
This is why the race condition handling above matters. Without it, multiple requests might each send the refresh token, and the second one would look exactly like a token theft to a server that implements rotation correctly.
The implementation on the client side does not change — you are already saving both the new access token and the new refresh token from the response. Token rotation is primarily a server-side concern. But you should understand why it exists, because it affects how you reason about error cases and how you test your refresh flow.
Checking token expiry on the client
You do not need to wait for a 401 to know your token is expired. The expiry time is right there in the JWT payload. You can decode it and check:
import 'dart:convert';
class JwtDecoder {
/// Returns the expiry time of a JWT, or null if the token
/// is malformed or has no exp claim.
static DateTime? getExpiry(String token) {
try {
final parts = token.split('.');
if (parts.length != 3) return null;
// Base64 padding — JWT omits trailing '=' characters
final payload = parts[1];
final normalised = base64.normalize(payload);
final decoded = utf8.decode(base64.decode(normalised));
final claims = json.decode(decoded) as Map<String, dynamic>;
final exp = claims['exp'] as int?;
if (exp == null) return null;
return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
} catch (_) {
return null;
}
}
/// Returns true if the token will expire within the given buffer.
/// A buffer of 30 seconds means "treat it as expired if it expires
/// within 30 seconds."
static bool isExpired(String token, {Duration buffer = Duration.zero}) {
final expiry = getExpiry(token);
if (expiry == null) return true; // Treat unparseable tokens as expired
return DateTime.now().toUtc().isAfter(
expiry.subtract(buffer).toUtc(),
);
}
}You can use this in the onRequest interceptor to proactively refresh before sending a request you know will fail:
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final tokens = await _tokenRepository.getTokens();
if (tokens == null) {
handler.next(options);
return;
}
// If the access token expires within 30 seconds, refresh first
if (JwtDecoder.isExpired(tokens.accessToken,
buffer: const Duration(seconds: 30))) {
final newTokens = await _performRefresh(tokens.refreshToken);
if (newTokens != null) {
options.headers['Authorization'] =
'Bearer ${newTokens.accessToken}';
}
} else {
options.headers['Authorization'] =
'Bearer ${tokens.accessToken}';
}
handler.next(options);
}This avoids the round trip of sending a request, getting a 401, refreshing, and retrying. It is a performance optimisation, not a security measure.
An important caveat: do not rely on client-side expiry checks for security decisions. The client's clock might be wrong. The device might be in a different timezone with a misconfigured UTC offset. The user might have manually set their clock forward or back. The server is always the authority on whether a token is valid. Client-side checks are useful for reducing unnecessary network calls. They are not a substitute for the server rejecting expired tokens.
Logout done right
Logout is not navigating to the login screen. That is the visible part. The actual logout is a series of cleanup operations that, if skipped, leave the user's session vulnerable even after they think they have signed out.
Here is the complete flow:
class AuthService {
final Dio _dio;
final AuthTokenRepository _tokenRepository;
final void Function() _resetAppState;
AuthService({
required Dio dio,
required AuthTokenRepository tokenRepository,
required void Function() resetAppState,
}) : _dio = dio,
_tokenRepository = tokenRepository,
_resetAppState = resetAppState;
Future<void> logout() async {
// 1. Invalidate the refresh token server-side
try {
final tokens = await _tokenRepository.getTokens();
if (tokens != null) {
await _dio.post(
'/auth/logout',
data: {'refreshToken': tokens.refreshToken},
);
}
} catch (_) {
// If the server call fails, continue with local cleanup.
// The token will expire on its own. Do not block logout
// on a network request.
}
// 2. Clear tokens from secure storage
await _tokenRepository.clearTokens();
// 3. Reset all in-memory state
_resetAppState();
}
}Each step matters for a specific reason.
Step 1: Server-side invalidation. When the server receives a logout request with the refresh token, it adds that token (or the entire token family) to a deny list. Even if an attacker has a copy of the refresh token, it will no longer work. This is the only step that actively revokes the session — everything else is local cleanup.
Note the try/catch. If the network request fails — the user is offline, the server is down, the token is already expired — you still proceed with local cleanup. You do not trap the user in a state where they cannot log out because the server is unreachable. The refresh token will expire on its own eventually. The local cleanup ensures the device is clean immediately.
Step 2: Clear secure storage. Removes both the access token and the refresh token from the device. After this, the interceptor will find no tokens and will not attach an Authorization header.
Step 3: Reset application state. This is the step most implementations miss. If your app uses BLoC, those blocs may hold user-specific data in memory — the user's profile, their settings, their message list. Navigating to the login screen does not clear that state. If another user logs in on the same device, they might briefly see the previous user's data before the new data loads.
What _resetAppState does depends on your state management approach. With BLoC, you close and recreate the blocs. With Riverpod, you invalidate the relevant providers. With GetIt, you unregister and re-register the singletons. The mechanism varies, but the requirement is the same: after logout, no trace of the previous user's data should exist in memory.
A minimal BLoC example:
// In your dependency injection setup
void resetAppState() {
// Close existing blocs
getIt<UserProfileBloc>().close();
getIt<NotificationsBloc>().close();
// Re-register fresh instances
getIt.resetLazySingleton<UserProfileBloc>();
getIt.resetLazySingleton<NotificationsBloc>();
}Edge cases worth thinking about
The core pattern handles the common case: token expires during active use, refresh succeeds, user continues without interruption. But several edge cases arise in real applications.
App backgrounded for hours
The user opens your app in the morning, switches to another app, and comes back in the afternoon. The access token expired hours ago. The refresh token may or may not still be valid.
This is not actually a special case if your interceptor is implemented correctly. The first request after returning will fail with a 401, the interceptor will attempt a refresh, and either it succeeds (the user continues) or it fails (the user sees the login screen). The user experience is seamless if the refresh token is still valid, and graceful if it is not.
Where it gets tricky is if your app performs work on resume — syncing local changes, fetching notifications, re-establishing a WebSocket connection. All of these requests will hit the 401 simultaneously. The race condition handling described above handles this, but test it. Background-then-resume is one of the most common sources of authentication bugs in mobile apps.
Refresh token also expired
If the refresh token has expired, the refresh endpoint will reject it. Your interceptor catches this case — the refresh call fails, _onSessionExpired fires, and the user is navigated to the login screen.
The key is to make this transition graceful. Do not show an error dialogue that says "Session expired" and then navigate. Do not show a blank screen. Navigate directly to the login screen with a brief, clear message: "Your session has expired. Please sign in again." Save any unsaved work to local storage before clearing the auth state, if your app has user-generated content.
Password changed on another device
The user changes their password on the web. All existing refresh tokens should be invalidated server-side. This is a server-side concern — when a password change occurs, the server invalidates all token families for that user.
From the app's perspective, the next refresh attempt will fail. The _onSessionExpired callback fires, and the user sees the login screen. They enter their new password and continue.
If your server does not invalidate refresh tokens on password change, you have a security gap. A stolen refresh token remains valid even after the user takes the corrective action of changing their password. This is worth verifying with your backend team — it is a server-side implementation detail that directly affects client-side security.
Wiring it all together
Here is how the interceptor, the repository, and the Dio instance connect:
Dio createAuthenticatedDio({
required String baseUrl,
required AuthTokenRepository tokenRepository,
required void Function() onSessionExpired,
}) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
dio.interceptors.add(AuthInterceptor(
dio: dio,
tokenRepository: tokenRepository,
onSessionExpired: onSessionExpired,
));
return dio;
}Notice that the Dio instance is passed to its own interceptor. This is intentional — the interceptor needs to make the refresh call and retry the original request using the same Dio instance (and therefore the same base URL, timeouts, and other interceptors). This circular reference is a normal and expected pattern with Dio interceptors.
What this article does not cover
This article covers the client-side implementation of the access/refresh pattern. It does not cover the server-side implementation — how to generate JWTs, how to store refresh tokens in a database, how to implement token families for rotation detection. Those are backend concerns with their own set of decisions and trade-offs.
It also does not cover certificate pinning, which prevents man-in-the-middle attacks from intercepting tokens in transit. Or biometric gating, which adds a device-level verification step before accessing stored tokens. Both are important layers in a complete security strategy, and both build on the foundation described here.
The access/refresh pattern is not the entire authentication story. But it is the part that determines how much damage a compromised token can do, and it is the part that most Flutter applications get wrong — either by using a single long-lived token, or by implementing refresh without handling the race condition, or by skipping server-side invalidation on logout.
Get this layer right, and the rest of your security measures have a solid foundation to build on.