Race-free automatic access/refresh token handling for Dio. Add one interceptor, write one function that calls your refresh endpoint, done.
- Single-flight refresh. 20 requests hit a 401 at once, and your refresh endpoint is called once. Everyone waits for that one refresh and is replayed with the new token, or they all fail together.
- Proactive refresh. Tokens are refreshed before they expire, using
expires_in, your ownexpiresAtor the JWTexpclaim, with a buffer for clock skew. - No surprise logouts. Network errors and timeouts during a refresh keep the tokens. Only a rejected refresh token (400/401/403,
null, orInvalidRefreshTokenException) ends the session. - No loops, no deadlocks. Each request is replayed at most once, and requests made by the refresher never trigger another refresh, even through the same
Dio. - Logout wins. Logging out during a refresh discards the refresh result, so the user is never silently logged back in.
- Flutter-friendly. An
authStatusstream forStreamBuilder, an app-resume observer, pluggable (secure) storage,Options().skipAuth(), and OAuth response parsing. - Every platform. Android, iOS, macOS, Windows, Linux and web. No
dart:ioanywhere.
dependencies:
smart_refresh_token: ^0.2.0import 'package:dio/dio.dart';
import 'package:smart_refresh_token/smart_refresh_token.dart';
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final auth = RefreshTokenInterceptor(
dio: dio,
tokenRefresher: (current, client) async {
// `client` is a plain Dio (same base URL and adapter, no interceptors).
final res = await client.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refresh_token': current.refreshToken},
);
// Parses access_token / refresh_token / expires_in / token_type, and
// keeps the old refresh token if the server did not rotate it.
return Credentials.fromOAuthJson(res.data!, previous: current);
},
);
void setUpApi() => dio.interceptors.add(auth);Log in and out:
// Login: public endpoint, no token attached.
final res = await dio.post<Map<String, dynamic>>(
'/auth/login',
data: {'email': email, 'password': password},
options: Options().skipAuth(),
);
await auth.setCredentials(Credentials.fromOAuthJson(res.data!));
// Every other request gets "Authorization: Bearer <token>" automatically.
final me = await dio.get('/me');
// Logout.
await auth.clearTokens();authStatus emits the current state right away (loading saved tokens if needed), then every change, including when a refresh token is rejected:
StreamBuilder<AuthStatus>(
stream: auth.authStatus,
builder: (context, snapshot) => switch (snapshot.data) {
AuthStatus.authenticated => const HomeScreen(),
AuthStatus.unauthenticated => const LoginScreen(),
_ => const SplashScreen(),
},
);Want a callback instead? Pass onAuthFailure: (reason) => router.go('/login'). It runs once per ended session, not once per failed request.
late final lifecycle = TokenRefreshLifecycleObserver(auth)..start();
// in dispose(): lifecycle.stop();On resume, an expiring token is refreshed immediately, so the first screen doesn't wait for it. A failed refresh here never throws or logs out on network errors.
Tokens are kept in memory by default (InMemoryTokenStorage). To persist them securely, implement the three-method TokenStorage, for example with flutter_secure_storage:
class SecureTokenStorage extends TokenStorage {
const SecureTokenStorage([this._storage = const FlutterSecureStorage()]);
final FlutterSecureStorage _storage;
static const _key = 'auth_credentials';
@override
Future<Credentials?> read() async {
final raw = await _storage.read(key: _key);
return raw == null
? null
: Credentials.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@override
Future<void> write(Credentials c) =>
_storage.write(key: _key, value: jsonEncode(c.toJson()));
@override
Future<void> delete() => _storage.delete(key: _key);
}
final auth = RefreshTokenInterceptor(
dio: dio,
tokenStorage: const SecureTokenStorage(),
tokenRefresher: refresher,
);Storage is read once (then cached in memory) and written after every refresh. Change credentials through setCredentials / clearTokens rather than writing to the storage directly.
Storage failures:
| Failure | What happens |
|---|---|
read() throws |
The request fails with RefreshFailureType.storage. The next request tries reading again. |
write() throws after a refresh |
The new tokens stay in memory, so the app keeps working (important with rotation, because the old refresh token is already dead). The error is logged. |
delete() throws in clearTokens() |
The user is still logged out in memory, and the error is rethrown to you. |
request A ─▶ 401 ─┐
request B ─▶ 401 ─┼─▶ one refresh ─▶ new token ─▶ A, B, C replayed (once each)
request C ─▶ wait ┘ │
└─ fails ─▶ A, B, C fail with the same RefreshTokenException
- One refresh at a time. The first request that needs a refresh starts it. Everything else that needs a token while it runs (new requests, other 401s,
refreshToken(),validCredentials()) awaits the same future. - Stale-token detection. Each request remembers which token it was sent with. If it gets a 401 but the current token is already different (a refresh finished meanwhile), it is replayed with the current token, without another refresh.
- Replay once. A replayed request that is rejected again is returned to you as-is. No loops.
- Refresh isolation. The refresher runs in a marked zone, and requests made from it skip this interceptor. So a refresh endpoint answering 401 ends the session instead of recursing, even if you pass the intercepted
DioasrefreshDio. - Session generations.
clearTokens()andsetCredentials()start a new generation. A refresh from an older generation can't write its result. Waiters get the new login's token, orRefreshFailureType.sessionChangedafter a logout. - Timeouts. A refresh is limited by
refreshTimeout(30 s by default). On timeout, the tokens are kept. If the slow refresh succeeds later, its result is still adopted, so a rotated refresh token is never lost. - Cancellation. A request whose
CancelTokenis cancelled while it waits for a refresh fails immediately with a cancel error. The shared refresh keeps going for everyone else.
When a token can't be refreshed, the request fails with a DioException. Its error is a RefreshTokenException, and response is the original 401 when there was one.
try {
await dio.get('/orders');
} on DioException catch (e) {
final error = e.error;
if (error is RefreshTokenException && error.isSessionEnded) {
// invalidRefreshToken or sessionChanged: show the login screen
} else if (error is RefreshTokenException && error.isTransient) {
// timeout / offline / 5xx / storage: show "try again"
}
}RefreshFailureType |
Cause | Tokens |
|---|---|---|
invalidRefreshToken |
refresher returned null, threw InvalidRefreshTokenException, got 400/401/403, or the refresh token expired |
deleted, onAuthFailure called |
timeout |
refresh took longer than refreshTimeout |
kept |
transient |
network error, 5xx, any other exception | kept |
sessionChanged |
logout during the refresh | already cleared |
storage |
TokenStorage.read() threw |
kept |
RefreshTokenInterceptor(
dio: dio,
tokenRefresher: refresher,
tokenStorage: const SecureTokenStorage(), // default: InMemoryTokenStorage()
onAuthFailure: (reason) {}, // once per ended session
refreshDio: authDio, // optional client for the refresher
retryConfig: const RetryConfig(), // optional network retries (off by default)
logger: const RefreshTokenLogger(enabled: true), // never logs token values
refreshConfig: RefreshConfig(
proactiveRefresh: true, // refresh before expiry
expirationBuffer: const Duration(minutes: 1), // also covers clock skew
refreshTimeout: const Duration(seconds: 30),
authorizationHeaderKey: 'Authorization',
headerBuilder: (c) => 'Bearer ${c.accessToken}', // or e.g. (c) => c.accessToken
isAuthFailure: (response) => response.statusCode == 401, // e.g. also 403
isRefreshTokenRejected: RefreshConfig.defaultIsRefreshTokenRejected,
onRefreshStart: () {},
onRefreshSuccess: (credentials) {},
onRefreshFailure: (error) {},
),
);- Expiry sources, in order:
Credentials.accessTokenExpireAt, then the JWTexpclaim. If neither is known, the token is only refreshed after a 401. - Short-lived tokens: the buffer is capped at half of a freshly issued token's lifetime, so a 30-second token with a 1-minute buffer doesn't refresh on every request.
- Clock skew:
Credentials.fromOAuthJsonturnsexpires_ininto an absolute time using the device clock, so there's no skew. For absolute JWTexpvalues, makeexpirationBufferat least as large as the clock drift you expect. - Skip auth per request:
Options().skipAuth(), orextra: {RefreshTokenInterceptor.skipAuthKey: true}. - Tokens outside Dio (WebSockets, image headers):
await auth.validCredentials()returns credentials that are valid now, refreshing first if needed. - Cookie-based refresh (common on web):
refreshTokenmay benull. Your refresher just calls the endpoint and the browser sends the cookie. - Network retries:
RetryConfigretries timeouts, connection errors and 408/429/5xx with exponential backoff and jitter. By default only idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) are retried, and retries stop as soon as the request is cancelled. - FormData bodies are cloned for replays. Requests with a
Streambody can't be replayed.
The interceptor works with any Dio HttpClientAdapter, so tests can use an in-process fake server. This package's own tests are good templates: test/support/fake_backend.dart. It works under testWidgets and fake time, and the clock: constructor parameter lets you control expiry.
example/ is a complete Flutter app with secure storage, an authStatus-driven UI, the lifecycle observer, and an in-app fake server. It runs offline on every platform. Its buttons let you expire the access token or revoke the refresh token server-side and watch "10 parallel requests → 1 refresh" happen.
cd example && flutter run0.2.0 is a breaking release that fixes several correctness bugs (see the CHANGELOG).
| 0.1.x | 0.2.0 |
|---|---|
RefreshTokenInterceptor(tokenStorage: s, tokenRefresher: f, onAuthFailure: g) |
RefreshTokenInterceptor(dio: dio, tokenStorage: s, tokenRefresher: f, onAuthFailure: g). dio is the Dio you add it to, and tokenStorage / onAuthFailure are now optional. |
tokenRefresher: (String refreshToken, Dio client) async {...} |
tokenRefresher: (Credentials current, Dio client) async {...}. Use current.refreshToken. |
onAuthFailure: () {...} |
onAuthFailure: (RefreshTokenException reason) {...} |
Credentials.refreshToken is String, accessTokenExpireAt is DateTime |
both are nullable now (cookie refresh, JWT exp fallback) |
RefreshConfig.parallelRefresh, refreshHeaders, enableLogging |
removed. Refreshes are always single-flight; set headers in your refresher; use logger: |
onRefreshSuccess(dynamic) / onRefreshFailure(dynamic) |
typed: Credentials / RefreshTokenException |
retryDio, RetryPolicy |
removed. Replays and retries go through your own dio, so they keep its adapter and interceptors. |
RetryConfig retried every method 3 times by default |
network retries are opt-in (retryConfig: RetryConfig()) and idempotent-only by default |
TokenStorage.hasCredentials() |
await auth.credentials != null |
refreshToken() returned bool |
returns Credentials and throws RefreshTokenException |
authorizationHeaderKey default 'authorization' |
'Authorization' (HTTP headers are case-insensitive; this only matters if you read the map yourself) |
expirationBuffer default 5 minutes |
1 minute (and capped at half the token lifetime) |
Requests without credentials were rejected and onAuthFailure was called |
they are sent without a token; the server decides |
Stored JSON from 0.1.x is still readable by Credentials.fromJson.
MIT