Caching Authenticated Images in Flutter

Mohamed Almadih

Mohamed Almadih

9/3/2025
4 min read
Caching Authenticated Images in Flutter

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:

1
class AuthenticatedImage extends ConsumerStatefulWidget {
2
final int imageId;
3
final BoxFit fit;
4
final double? width;
5
final double? height;
6
7
const AuthenticatedImage({
8
super.key,
9
required this.imageId,
10
this.fit = BoxFit.cover,
11
this.width,
12
this.height,
13
});
14
15
@override
16
ConsumerState<AuthenticatedImage> createState() => _AuthenticatedImageState();
17
}
18
19
class _AuthenticatedImageState extends ConsumerState<AuthenticatedImage> {
20
Key _imageKey = UniqueKey();
21
22
void _retry() {
23
setState(() {
24
_imageKey = UniqueKey();
25
});
26
}
27
28
@override
29
Widget build(BuildContext context) {
30
final imagesCacheManager = ref.watch(imageCacheManagerProvider);
31
final String imageUrl = '/image/${widget.imageId}';
32
33
return CachedNetworkImage(
34
key: _imageKey,
35
imageUrl: imageUrl,
36
cacheManager: imagesCacheManager,
37
imageBuilder: (context, imageProvider) => Image(
38
image: imageProvider,
39
fit: widget.fit,
40
width: widget.width,
41
height: widget.height,
42
),
43
placeholder: (context, url) => const Center(
44
child: CircularProgressIndicator(strokeWidth: 3),
45
),
46
errorWidget: (context, url, error) => Column(
47
mainAxisAlignment: MainAxisAlignment.center,
48
children: [
49
const Icon(Icons.broken_image, color: Colors.grey, size: 40),
50
TextButton(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

1
class AuthenticatedCacheManager extends CacheManager {
2
static const key = 'authenticatedImageCache_v3';
3
4
AuthenticatedCacheManager({required dio.Dio dio}): super(
5
Config(
6
key,
7
repo: JsonCacheInfoRepository(databaseName: key),
8
fileService: CustomFileService(dio),
9
),
10
);
11
}

This cache manager uses a CustomFileService that makes authenticated requests.

Custom File Service

1
class CustomFileService extends FileService {
2
final dio.Dio _dio;
3
4
CustomFileService(this._dio);
5
6
@override
7
Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) async {
8
try {
9
final token = 'auth token'; // Fetch from secure storage
10
final uri = Uri.parse(url);
11
final apiPath = uri.path;
12
13
_dio.options = dio.BaseOptions(baseUrl: ApiConfig.baseUrl);
14
15
final response = await _dio.get(
16
apiPath,
17
options: dio.Options(
18
responseType: dio.ResponseType.bytes,
19
headers: {'Authorization': 'Bearer $token'},
20
),
21
);
22
23
final streamedResponse = http.StreamedResponse(
24
Stream.value(response.data as List<int>),
25
response.statusCode!,
26
request: http.Request('GET', uri),
27
contentLength: response.data.length,
28
headers: {
29
for (var entry in response.headers.map.entries)
30
entry.key: entry.value.join(','),
31
},
32
);
33
34
return HttpGetResponse(streamedResponse);
35
} on dio.DioException catch (e) {
36
final streamedResponse = http.StreamedResponse(
37
const Stream.empty(),
38
e.response?.statusCode ?? 500,
39
);
40
return 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:

1
return 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. 🚀