Production-Ready Authentication in Flutter: Tokens, Refresh and Routing
Production-ready authentication in Flutter: secure token storage, a dio interceptor that refreshes once, go_router redirects, Sign in with Apple and account deletion.

A login screen takes an afternoon. Authentication that survives production takes considerably longer, because the hard parts aren’t on the login screen at all. They show up when a token expires while five requests are in flight, when a user upgrades their phone, or when App Review rejects your build over a button you didn’t know you needed.
I’ve built auth flows on Firebase and on custom .NET backends, and the same handful of problems shows up every time. This post is the checklist I work through now, with the code I actually use.
Firebase Auth or your own backend?
Choose this first, because it shapes everything else.
Firebase Auth (or a similar hosted provider) handles password hashing, email verification, social providers, token refresh and session persistence for you. The SDK refreshes ID tokens automatically and exposes authStateChanges() as a stream. If you’re already on Firebase, or you don’t have a backend team, it’s usually the right call.
Your own backend issuing JWTs makes sense when you already have a server that owns users and permissions, when you need full control over the session model, or when vendor lock-in is a real concern. The usual shape is:
- A short-lived access token (minutes, not days) sent as a
Bearerheader on every request. - A long-lived refresh token used only to get a new access token, ideally rotated on every use so a stolen one has a short shelf life.
The rest of this post assumes the second option, but routing, logout and store requirements apply either way.
Store tokens in secure storage, not SharedPreferences
SharedPreferences writes to a plain XML file on Android and a plist on iOS. Anything on a rooted device or in an unencrypted backup can read it. Tokens go in flutter_secure_storage, which uses the Keychain on iOS and encrypted storage backed by the Android Keystore.
class TokenStore {
TokenStore(this._storage);
final FlutterSecureStorage _storage;
static const _accessKey = 'access_token';
static const _refreshKey = 'refresh_token';
Future<String?> get accessToken => _storage.read(key: _accessKey);
Future<String?> get refreshToken => _storage.read(key: _refreshKey);
Future<void> save({required String access, required String refresh}) async {
await _storage.write(key: _accessKey, value: access);
await _storage.write(key: _refreshKey, value: refresh);
}
Future<void> clear() => _storage.deleteAll();
}
One iOS gotcha: Keychain entries survive an app uninstall. If a user deletes and reinstalls the app, they may find themselves “already logged in” with stale tokens. A common fix is to write a flag to SharedPreferences on first launch; if the flag is missing, assume a fresh install and clear secure storage.
Secure storage protects tokens at rest. It doesn’t make the app a vault, which is a bigger topic I cover in the mobile app security checklist.
An interceptor that refreshes once
Here’s the bug almost every hand-rolled implementation has. The access token expires. The home screen fires four requests at once. All four get 401. All four call the refresh endpoint. If your server rotates refresh tokens, the first refresh succeeds and the other three use a token that’s now invalid, and the user gets logged out for no visible reason.
The fix is to make sure only one refresh happens and everyone else waits for it. dio’s QueuedInterceptor helps a lot here: it processes onError calls one at a time, so the second 401 isn’t handled until the first refresh has finished.
class AuthInterceptor extends QueuedInterceptor {
AuthInterceptor(this._dio, this._tokens, this._refreshDio, this._onSessionExpired);
final Dio _dio; // the main client
final Dio _refreshDio; // a bare client with no auth interceptor
final TokenStore _tokens;
final void Function() _onSessionExpired;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _tokens.accessToken;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final response = err.response;
final alreadyRetried = err.requestOptions.extra['retried'] == true;
if (response?.statusCode != 401 || alreadyRetried) {
return handler.next(err);
}
final sentToken = err.requestOptions.headers['Authorization'];
final current = await _tokens.accessToken;
// Someone else already refreshed while this request waited in the queue.
if (current != null && sentToken != 'Bearer $current') {
return handler.resolve(await _retry(err.requestOptions, current));
}
try {
final refresh = await _tokens.refreshToken;
final res = await _refreshDio.post('/auth/refresh', data: {'refreshToken': refresh});
await _tokens.save(
access: res.data['accessToken'] as String,
refresh: res.data['refreshToken'] as String,
);
handler.resolve(await _retry(err.requestOptions, res.data['accessToken'] as String));
} on DioException {
await _tokens.clear();
_onSessionExpired();
handler.next(err);
}
}
Future<Response<dynamic>> _retry(RequestOptions options, String token) {
options.headers['Authorization'] = 'Bearer $token';
options.extra['retried'] = true;
return _dio.fetch(options);
}
}
Three details matter:
- The refresh call uses a separate
Dioinstance with no auth interceptor. Otherwise a 401 from the refresh endpoint triggers another refresh, and you’ve built a loop. - The token comparison is what prevents the stampede. Queued requests check whether the token they were sent with is still current; if it isn’t, they just retry with the new one.
- The
retriedflag stops an endpoint that always returns 401 (a permissions bug, say) from retrying forever.
If refresh fails, the session is genuinely over. Clear tokens and let the routing layer send the user to login.
Auth state as a stream that drives routing
I don’t navigate to the login screen from inside network code. Instead, auth state lives in one place, and the router reacts to it.
enum AuthStatus { unknown, signedIn, signedOut }
class AuthController extends ChangeNotifier {
AuthStatus _status = AuthStatus.unknown;
AuthStatus get status => _status;
void set(AuthStatus status) {
if (status == _status) return;
_status = status;
notifyListeners();
}
}
go_router accepts any Listenable as refreshListenable and re-runs redirect whenever it notifies:
GoRouter buildRouter(AuthController auth) => GoRouter(
refreshListenable: auth,
redirect: (context, state) {
final loc = state.matchedLocation;
switch (auth.status) {
case AuthStatus.unknown:
return loc == '/splash' ? null : '/splash';
case AuthStatus.signedOut:
return loc == '/login' ? null : '/login';
case AuthStatus.signedIn:
return (loc == '/login' || loc == '/splash') ? '/' : null;
}
},
routes: [/* ... */],
);
The unknown state is important. On startup you don’t know whether the user is signed in until you’ve read secure storage, and without a splash state the app flashes the login screen for a frame before jumping home. If you also support deep links, keep the original location so you can send the user there after they log in; I go into that in deep linking with go_router.
Sign in with Apple is not optional (usually)
If your iOS app offers third-party or social login such as Google or Facebook, Apple’s App Store Review Guideline 4.8 generally requires you to also offer a login option with specific privacy properties: it limits data collection to name and email, lets users keep their email private, and doesn’t track them for advertising without consent. Sign in with Apple is the option most apps use to meet that. There are exceptions, for example apps that use only their own account system or enterprise sign-in, so read the current guideline for your case.
In practice: if there’s a “Continue with Google” button on iOS, plan for Sign in with Apple from day one. Your backend also has to verify Apple’s identity token, and Apple only sends the user’s name on the first authorization, so save it then.
Logout means everything
A logout that only deletes the access token isn’t a logout. My logout routine:
- Calls the backend to revoke the refresh token (best effort; don’t block on it if offline).
- Clears secure storage.
- Clears cached user data: local databases, image caches that could show private content, and in-memory state in your providers or blocs.
- Unregisters the push notification token for this user on the server, so the next person on this device doesn’t get their notifications.
- Signs out of social SDKs (Google, Apple, Firebase) so the next login actually shows the account picker.
- Sets auth status to
signedOutand lets the router do the rest.
The test I always run: log in as user A, log out, log in as user B, and check that no screen shows a single trace of A.
Account deletion is a requirement
Apple requires apps that support account creation to let users initiate account deletion from within the app. Google Play also requires apps with account creation to offer deletion in the app, plus a web link where users can request deletion without reinstalling. “Email support to delete your account” doesn’t meet either.
Build it as a real backend feature: a confirmation step, reauthentication for sensitive accounts, deletion (or anonymization where you’re legally required to keep records) of user data, and cancellation guidance for active subscriptions, because deleting an account doesn’t cancel an App Store subscription. Then run the full logout routine.
Takeaways
- Pick Firebase Auth or your own JWT backend deliberately; the app-side problems are mostly the same.
- Keep tokens in
flutter_secure_storage, and handle Keychain entries that survive reinstalls. - Use a queued interceptor that refreshes once, compares tokens, and retries each request at most once.
- Drive navigation from a single auth state listenable, including an
unknownstartup state. - Plan Sign in with Apple and in-app account deletion before you submit, not after rejection.
- Logout should clear tokens, caches, push registrations and social sessions.
Auth is rarely the feature anyone wants to talk about, but it’s the one every other feature sits on top of.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).