Bidev

Handling Expired and Refreshed JWTs in a Flutter App

Bilal Fali3 min read
Handling Expired and Refreshed JWTs in a Flutter App

A JWT expiring while a user is actively using your app isn't an edge case you need to defend against. It's a guaranteed event that will happen to every user, every session, on a predictable schedule set by however long you configured the access token's lifetime. Treating it as a rare failure instead of a routine part of the request lifecycle is why so many apps handle it by just logging the user out and making them sign in again, which is a bad experience for something that isn't actually an error.

What a decoded token can and can't tell you

The exp claim in a JWT's payload is a Unix timestamp, and you can decode it client-side without verifying the signature, since you're only reading a claim, not trusting it for authorization decisions:

import 'dart:convert';

DateTime? getTokenExpiry(String jwt) {
  final parts = jwt.split('.');
  if (parts.length != 3) return null;
  final payload = json.decode(
    utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))),
  );
  final exp = payload['exp'] as int?;
  if (exp == null) return null;
  return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
}

This tells you when the token says it expires. It doesn't verify the token is legitimate, that verification always has to happen server-side against the real signing key. Client-side decoding here is purely for deciding when to proactively refresh, not for any security decision.

The actual pattern: intercept 401s, refresh once, retry the original request

Rather than trying to preemptively refresh based on the decoded expiry (which adds complexity and can still race against clock skew), the more robust pattern is reactive: let the request fail with a 401, refresh the token, then retry the exact request that failed. Using Dio, that's an interceptor:

class AuthInterceptor extends Interceptor {
  final Dio dio;
  final AuthRepository authRepository;
  bool _isRefreshing = false;

  AuthInterceptor(this.dio, this.authRepository);

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401 && !_isRefreshing) {
      _isRefreshing = true;
      try {
        final newToken = await authRepository.refreshToken();
        _isRefreshing = false;

        // Retry the original request with the new token
        final retryRequest = err.requestOptions;
        retryRequest.headers['Authorization'] = 'Bearer $newToken';
        final response = await dio.fetch(retryRequest);
        return handler.resolve(response);
      } catch (e) {
        _isRefreshing = false;
        await authRepository.logout(); // refresh itself failed, this is a real logout
        return handler.next(err);
      }
    }
    return handler.next(err);
  }
}

The _isRefreshing flag matters more than it looks like it should. Without it, several concurrent requests all failing with 401 at the same time each try to refresh independently, which can invalidate a refresh token that another request already used successfully, turning one expired-token event into several failed requests instead of one clean recovery.

When to actually force a logout

A logout should happen when the refresh call itself fails, not when the access token expires. An expired access token with a valid refresh token is a normal, silent, invisible-to-the-user event. A failed refresh (the refresh token is also expired, revoked, or invalid) is the real signal that the session is genuinely over and the user needs to sign in again.

To inspect what's actually inside a token you're debugging, decode it with the JWT decoder, keeping in mind it only decodes, it doesn't verify.

Share this

Skip the boilerplate

Production-ready Flutter starter kit with Firebase Auth, Firestore, Cloud Functions, push notifications, and Clean Architecture — ship your app in days, not months.

Get Flutter Firebase Kit$5

Did this article save you time?

I write these for free. If it helped, a coffee keeps me going — and more articles coming.

Buy me a coffee

Comments

Comments

Leave a comment

0/2000

Comments appear after review.