Complete API Interceptor: Silent JWT Token Refresh Without Losing User Data Fields

Hello FlutterFlow community!

What this script does:

  1. Auto-Checks Expiry: Automatically validates your JWT token lifetime right before every single API request goes out.

  2. Pre-emptive Refresh: If the access token has less than 10 minutes left, it transparently pauses the outgoing call to execute a RefreshTokenCall first.

  3. Fixes Race Conditions: Uses a static Future link. If your UI fires 5 parallel API calls on page load, they all reuse the same single refresh task instead of spamming your server.

  4. Preserves User Profiles: Unlike default Custom Auth boilerplate setups, this code properly re-injects authManager.userData. Your custom properties like first_name, last_name, roles, or avatars will not be wiped out during a refresh!

  5. Safe Route Redirection: If the refresh token itself is invalid or expired, the script signs the user out and safely redirects them to the login page using GoRouter’s global nav key, bypassing typical Flutter layout context issues.

// Automatic FlutterFlow imports
import '/backend/schema/structs/index.dart';
import '/backend/schema/enums/enums.dart';
import '/actions/actions.dart' as action_blocks;
import '/app_events/index.dart';
import '/flutter_flow/flutter_flow_theme.dart';
import '/flutter_flow/flutter_flow_util.dart';
import '/custom_code/actions/index.dart'; // Imports other custom actions
import '/flutter_flow/custom_functions.dart'; // Imports custom functions
import 'package:flutter/material.dart';
// Begin custom action code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!

import 'package:jwt_decoder/jwt_decoder.dart';
import '/backend/api_requests/api_interceptor.dart';
import '/auth/custom_auth/auth_util.dart';
import '/backend/api_requests/api_calls.dart';

class ExpiryCheckInterceptor extends FFApiInterceptor {
  // Static Future pointer to prevent Race Conditions.
  // If 5 API calls trigger simultaneously, they will all await this single
  // refresh process instead of flooding your backend with 5 separate refresh requests.
  static Future<bool>? _refreshFuture;

  @override
  Future<ApiCallOptions> onRequest({
    required ApiCallOptions options,
  }) async {
    print("Checking JWT token expiry now");

    // Fetch the current authentication state from FlutterFlow's auth manager
    final tokenExpiration = authManager.tokenExpiration;
    final refreshToken = authManager.refreshToken;

    // Check if the user is authenticated (tokens exist)
    if (tokenExpiration != null && refreshToken != null) {
      print("Got token expiration and refresh token");
      final currentTime = DateTime.now();
      final timeDifference = tokenExpiration.difference(currentTime);

      // If the token is already expired OR will expire within the next 10 minutes
      if (timeDifference.isNegative || timeDifference.inMinutes <= 10) {
        print('Token is expired or will expire soon. Refreshing...');

        // Execute token refresh (or hook into an already ongoing refresh process)
        _refreshFuture ??= _performTokenRefresh(refreshToken);
        final isSuccess = await _refreshFuture;
        _refreshFuture = null; // Reset the future after completion

        if (isSuccess == true) {
          // If refresh succeeds, inject the NEW accessToken into the current request header
          final currentToken = authManager.authenticationToken;
          options.headers['Authorization'] = 'Bearer $currentToken';
        } else {
          print(
              'Token refresh failed. Signing out user and redirecting to login.');

          // 1. Clear user session data within FlutterFlow
          authManager.signOut();

          // 2. Safely retrieve the global app navigation context (GoRouter)
          final globalContext = appNavigatorKey.currentContext;

          if (globalContext != null) {
            // Use addPostFrameCallback to ensure the redirection triggers right after
            // the current layout build finishes (prevents Flutter lifecycle crashes)
            WidgetsBinding.instance.addPostFrameCallback((_) {
              globalContext.goNamed(
                  'login'); // 'login' must exactly match your Page Name/URL in FF
            });
          } else {
            print('Error: Global context navigation key is null.');
          }
        }
      }
    }

    return options;
  }

  // Private method that handles the actual refresh API call
  Future<bool> _performTokenRefresh(String refreshToken) async {
    try {
      // Ensure your API Call in FlutterFlow's API Manager is named exactly 'RefreshToken'.
      // It must accept 'refreshToken' as a variable in its settings.
      final response = await RefreshTokenCall.call(refreshToken: refreshToken);

      if (response.statusCode == 200) {
        print('Token refreshed successfully.');
        final responseBody = response.jsonBody;

        // Extract new tokens from response payload (adjust ['access']/['refresh'] keys to match your backend)
        final newAccessToken = responseBody['access'] as String?;
        final newRefreshToken = responseBody['refresh'] as String?;

        if (newAccessToken == null) {
          print('Access token missing in response');
          return false;
        }

        // Decode the new expiration timestamp from the JWT payload using the jwt_decoder package
        final expirationDate = JwtDecoder.getExpirationDate(newAccessToken);

        // Retain the current user state to prevent updateAuthUserData from wiping it out
        final uid = authManager.uid;
        final currentUser = authManager.userData;

        // Update FlutterFlow's session data while preserving all custom fields
        authManager.updateAuthUserData(
          authenticationToken: newAccessToken,
          tokenExpiration: expirationDate,
          // Fallback to the old refresh token if the backend doesn't rotate it
          refreshToken: newRefreshToken ?? refreshToken,
          authUid: uid,
          userData: currentUser,
        );
        return true;
      } else {
        print('Failed to refresh token: ${response.statusCode}');
        return false;
      }
    } catch (e) {
      print('Error while refreshing token: $e');
      return false;
    }
  }

  @override
  Future<ApiCallResponse> onResponse({
    required ApiCallResponse response,
    required Future<ApiCallResponse> Function() retryFn,
  }) async {
    return response;
  }
}
5