Caching Authenticated Images in Flutter

Mohamed Almadih

The Problem
I was building a Flutter mobile app that supports offline usage. One of the key features was the ability to view images even when offline, which meant I needed a proper caching solution.
If the images were public, this would’ve been easy — Flutter already has great packages for caching images. The challenge was that my images were behind authentication, requiring a token in the request headers. Unfortunately, most caching libraries in Flutter only take a simple imageUrl
, with no option to inject headers.
The Catch
So the real issue wasn’t just caching — it was authenticated caching. Every image request needed to include an Authorization
header. None of the out-of-the-box solutions handled this scenario.
That’s where my custom solution came in.
My Approach
I discovered the excellent cached_network_image
package. The nice part about this library is that it allows you to provide your own cache manager. This gave me full control over how requests were made and stored.
To implement it, I built a custom cache manager using flutter_cache_manager
. The key step here was creating a custom file service to inject my authentication headers.
Code Time
Let’s start with the widget that displays authenticated images:
1class AuthenticatedImage extends ConsumerStatefulWidget {2final int imageId;3final BoxFit fit;4final double? width;5final double? height;67const AuthenticatedImage({8super.key,9required this.imageId,10this.fit = BoxFit.cover,11this.width,12this.height,13});1415@override16ConsumerState<AuthenticatedImage> createState() => _AuthenticatedImageState();17}1819class _AuthenticatedImageState extends ConsumerState<AuthenticatedImage> {20Key _imageKey = UniqueKey();2122void _retry() {23setState(() {24_imageKey = UniqueKey();25});26}2728@override29Widget build(BuildContext context) {30final imagesCacheManager = ref.watch(imageCacheManagerProvider);31final String imageUrl = '/image/${widget.imageId}';3233return CachedNetworkImage(34key: _imageKey,35imageUrl: imageUrl,36cacheManager: imagesCacheManager,37imageBuilder: (context, imageProvider) => Image(38image: imageProvider,39fit: widget.fit,40width: widget.width,41height: widget.height,42),43placeholder: (context, url) => const Center(44child: CircularProgressIndicator(strokeWidth: 3),45),46errorWidget: (context, url, error) => Column(47mainAxisAlignment: MainAxisAlignment.center,48children: [49const Icon(Icons.broken_image, color: Colors.grey, size: 40),50TextButton(onPressed: _retry, child: const Text('Retry')),51],52),53);54}55}
Here, imageCacheManagerProvider
is a Riverpod provider that holds our custom cache manager, keeping it alive as a singleton.
Custom Cache Manager
1class AuthenticatedCacheManager extends CacheManager {2static const key = 'authenticatedImageCache_v3';34AuthenticatedCacheManager({required dio.Dio dio}): super(5Config(6key,7repo: JsonCacheInfoRepository(databaseName: key),8fileService: CustomFileService(dio),9),10);11}
This cache manager uses a CustomFileService
that makes authenticated requests.
Custom File Service
1class CustomFileService extends FileService {2final dio.Dio _dio;34CustomFileService(this._dio);56@override7Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) async {8try {9final token = 'auth token'; // Fetch from secure storage10final uri = Uri.parse(url);11final apiPath = uri.path;1213_dio.options = dio.BaseOptions(baseUrl: ApiConfig.baseUrl);1415final response = await _dio.get(16apiPath,17options: dio.Options(18responseType: dio.ResponseType.bytes,19headers: {'Authorization': 'Bearer $token'},20),21);2223final streamedResponse = http.StreamedResponse(24Stream.value(response.data as List<int>),25response.statusCode!,26request: http.Request('GET', uri),27contentLength: response.data.length,28headers: {29for (var entry in response.headers.map.entries)30entry.key: entry.value.join(','),31},32);3334return HttpGetResponse(streamedResponse);35} on dio.DioException catch (e) {36final streamedResponse = http.StreamedResponse(37const Stream.empty(),38e.response?.statusCode ?? 500,39);40return HttpGetResponse(streamedResponse);41}42}43}
Now, every image fetch includes the Bearer token in the headers, and the cache manager handles storing the result locally.
The Missing Piece
At first, I was confused why caching still didn’t work properly. Then I realized: caching isn’t just a client-side concern. The server must allow caching.
That means setting proper Cache-Control
headers on the API response. For example, in a Laravel controller:
1return response($file, 200)2->header('Content-Type', $mimeType)3->header('Cache-Control', 'public, max-age=2592000'); // 30 days
Without these headers, the cache manager assumes the resource is not cacheable, no matter what you do on the client.
Conclusion
This might look like an over-engineered setup for something as “simple” as images, but it solved the real-world challenge of caching authenticated resources.
The key takeaways:
- Use
cached_network_image
with a custom cache manager. - Write a custom
FileService
to inject auth headers. - Don’t forget server-side caching headers — they’re just as important.
With this setup, my users can now view their private images offline, without re-fetching them every time. 🚀