Reducing Storage Egress Costs with Cloud CDN and Signed URLs in Dart

Reducing Storage Egress Costs with Cloud CDN and Signed URLs in Dart

Learn how to significantly reduce Google Cloud Storage and Firebase egress costs by setting up Cloud CDN with signed URLs, featuring a complete Dart backend implementation.

I absolutely love when I attend a conference and have an interaction with a customer where the problem they are having eats away at me. Recently I attended Cloud Next 2026 in Las Vegas and had a customer meeting where the customer expressed frustration with Firebase Storage egress costs and I stayed up pretty late exploring the solution. This is my solution to their issue.

Egress on media from Google Cloud Storage

Customer states that depending on network usage of their services that their egress would cost them quite a bit each month. The setup is that the customer has a series of media files that they are storing and delivering to their customers.

The media files do not change often and are only for paying customers meaning that they cannot serve the media to anyone online, just those logged in. There are two things that make this interesting. The first was did Cloud CDN support signed URLs for content and the second was what is the break even point for Cloud CDN vs Google Cloud Storage for egress costs only?

Signed URLs

The first experiment I did was to determine whether I could sign URLs. I was able to find this documentation on generating signed URLs from Google Cloud. It seems that it’s likely I can generate a signed URL for a Cloud CDN as long as I grab the base64 encoded key bytes from the cloud console on key creation. I then went off to experiment creating a CDN.

Cloud CDN configuration

In the Cloud CDN menu, I selected to Add an Origin at the top of the page. I was then asked to pick out an Origin Type, I chose Backend Bucket, Define my backend bucket, I choose an Existing Backend Bucket which was the folder with all of my media content in it, and then select an origin name. Once I had done that, I needed to add a Load Balancer. This was the most stressful part for me because I was unaware that I needed a Load Balancer and knew there was a minimum monthly charge for load balancing. For one forwarding rule which just describes how to route requests to your load balancer to reroute to your CDN, it costs 18.25 a month. You can include four more forwarding rules without exceeding this price from my read of the documentation. This is where I selected to create a new load balancer and continued onward. Now for the Cache Performance menu. In this menu, I chose the default Cache static Content (recommended) for the Cache mode. Since the customer is only serving static media content, I think we can set a TTL of 1 year for the Client, Default, and Maximum settings. This means that clients will likely never need to redownload content and additionally, they likely not need to fill the cache as often with a lower TTL. I then left all the other options set to the default and moved down to the Restricted Content section. In this section I chose to Restrict access using signed URLs and signed cookies and added a new URL signing key. I filed in the name I wanted for the key and selected the option to Automatically generate signing keys. Here I needed to grab the Signing key value from the dialog box and store it safely before deploying the app to my server, perhaps in a Google Cloud Secret. Anyone with this string can sign requests to my CDN and get data which would not be helpful if I am attempting to restrict access using signed urls. Finally, I click Done and wait for the Cloud CDN to be ready.

Generating signed URLs

Since the customer is a Flutter developer, I wanted to make sure that I provided them with a code sample for Signed URLs in their preferred backend language, Dart! For this I leaned on Gemini to help me convert the existing code samples from the docs to Dart and came up with the following code snippet:

import 'dart:convert';
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:dotenv/dotenv.dart';

final env = DotEnv(includePlatformEnvironment: true);

String generateSignedUrl(String imageName, {int? expirationTime}) {
  if (File('.env').existsSync()) {
    env.load(['.env']);
  }

  final address = Platform.environment['CDN_ADDRESS'] ?? env['CDN_ADDRESS'];
  final baseUrl = 'http://$address';
  final url = '$baseUrl/$imageName';

  final keyName = env['CDN_KEY_NAME'] ?? Platform.environment['CDN_KEY_NAME'];
  final base64Key =
      env['CDN_BASE64_KEY'] ?? Platform.environment['CDN_BASE64_KEY'];

  if (keyName == null ||
      keyName.isEmpty ||
      base64Key == null ||
      base64Key.isEmpty) {
    throw StateError(
      'CDN_KEY_NAME or CDN_BASE64_KEY environment variable is missing',
    );
  }

  final resolvedExpirationTime =
      expirationTime ??
      (DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000 + 3600);

  // Decode the base64url-encoded key, adding padding if needed
  String padBase64(String input) {
    final rem = input.length % 4;
    if (rem == 0) return input;
    return input + '=' * (4 - rem);
  }

  final List<int> keyBytes = base64Url.decode(padBase64(base64Key));

  // Determine URL separator
  final separator = url.contains('?') ? '&' : '?';

  // Construct URL string to sign
  final urlToSign =
      '$url${separator}Expires=$resolvedExpirationTime&KeyName=$keyName';

  // Create HMAC-SHA1 signature
  final hmacSha1 = Hmac(sha1, keyBytes);
  final digest = hmacSha1.convert(utf8.encode(urlToSign));

  // Encode signature using base64url (keep padding)
  final signature = base64Url.encode(digest.bytes);

  // Construct final signed URL
  return '$urlToSign&Signature=$signature';
}

You can see that I am using a dotenv file to store a CDN_KEY_NAME, CDN_BASE64_KEY, and a CDN_ADDRESS. The CDN_KEY_NAME value comes from the key name we specified in the previous step. The CDN_BASE64_KEY is the Signing Key Value that we also specified in a previous step. Finally, to get the CDN_ADDRESS, I needed to know the address of my load balancer which I can retrieve from the following gcloud command.

gcloud compute forwarding-rules list --global

In the output I looked for the entry ${LOAD_BALANCER_NAME}-forwarding-rule-ipv4 and noted its IP_ADDRESS. Once I had that, I used a shelf server to handle the signing of URLs with some Firebase middleware to confirm the users custom claims from their Firebase Authentication Token and I had a working CDN.

Middleware authMiddleware() {
  return (Handler innerHandler) {
    return (Request request) async {
      if (env['ENABLE_AUTH_VERIFICATION'] != 'false' && Platform.environment['ENABLE_AUTH_VERIFICATION'] != 'false') {
        final authHeader = request.headers['Authorization'];
        if (authHeader == null || authHeader.isEmpty || !authHeader.startsWith('Bearer ')) {
          return Response.forbidden(jsonEncode({'error': 'Missing or invalid Authorization header'}));
        }

        final idToken = authHeader.substring(7);

        FirebaseApp getFirebaseApp() {
          try {
            return FirebaseApp.instance;
          } catch (_) {
            return FirebaseApp.initializeApp();
          }
        }

        final app = getFirebaseApp();
        final auth = app.auth();

        if (Platform.environment['TEST_ENV'] != 'true') {
          try {
            await auth.verifyIdToken(idToken);
          } catch (e) {
            return Response.forbidden(jsonEncode({'error': 'Token verification failed', 'details': e.toString()}));
          }
        }

        final decodedJWT = dart_jsonwebtoken.JWT.decode(idToken);
        final payload = decodedJWT.payload as Map;

        if (payload['paidUser'] != true) {
          return Response.forbidden(jsonEncode({'error': 'User does not have paidUser privilege'}));
        }
      }

      return await innerHandler(request);
    };
  };
}


Future<Response> _imageHandler(Request request) async {
  try {
    final imageName = request.params['image_name'];
    if (imageName == null || imageName.isEmpty) {
      return Response.badRequest(
        body: jsonEncode({'error': 'Missing image name'}),
      );
    }

    final signedUrl = generateSignedUrl(imageName);

    return Response.ok(
      jsonEncode({'signed_url': signedUrl}),
      headers: {'Content-Type': 'application/json'},
    );
  } catch (e) {
    return Response.internalServerError(
      body: jsonEncode({'error': 'Failed to generate signed URL', 'details': e.toString()}),
    );
  }
}

Break even point

This got me wondering what the minimum break even point is for egress costs on Cloud CDN are and just based on back of napkin math where Fixed Price/(Normal price - Discount Price) = break even price. Using this formula:

(Load balancer fixed price)/(Storage egress costs - CDN egress costs)

otherwise written as 18.25/(0.12-0.8), I got a break even point of 456.25 gigabytes of network egress which equates to a break even price of $54.75 USD per month. Therefore, if you want to save on network egress costs just from storage, if you send out about 450+ gigabytes a month, you are better off using Cloud CDN.

This price does ignore other costs like cache fill requests but since the cache fill we count as a yearly expense due to our long cache TTL, I felt like we could exclude it from our calculations.

Are there other options?

Based on the limited information I had when speaking to the customer this seemed like a good solution. One other solution I thought of after we spoke was that we could ask the customer to explore an LRU cache in Flutter that could hold onto media content for sometime as the media may be frequently visited media but I didn’t have time to explore it. Maybe my friend Rody has some ideas here?

Related Content

firebase • Apr 16, 2026

Implement hybrid inference in Android using Firebase AI Logic

In this deep dive, Nohe explores how to implement the hybrid SDK for Firebase AI Logic on Android. One of the biggest headaches in mobile AI is deciding between a cloud model (reliable but costly) and an on-device model (fast but fragmented). Now, you don't have to choose. With Hybrid Inference, your app can prefer the local model already managed by Android’s AICore and seamlessly fall back to Gemini 3.1 Flash in the cloud if the device isn't compatible.

Watch on YouTube
firebase • Mar 19, 2026

Cloud Shell in Firebase Console

Simplify your Firebase development workflow by using Cloud Shell directly within the Firebase Console. Nohe demonstrates how Cloud Shell eliminates the need for local tool installations and complex environment setups, allowing you to control Firebase projects, deploy Cloud Functions, and experiment with new web apps effortlessly. Discover how built-in tools like Node.js, Git, Firebase CLI, and Gemini CLI enable tasks such as image conversion and web app deployment, all from a pre-authenticated Google Cloud environment.

Watch on YouTube