A guide to Firebase Storage download URLs and tokens

June 20, 2020

A file stored in Cloud Storage for Firebase can be accessed in (at least) three ways:

  • Persistent download URLs (getDownloadUrl()): Public and long-lived, but hard to guess
  • Signed, short-lived URLs (getSignedUrl()): Public, short-lived, and hard to guess
  • Public download URLs: Public, persistent, without security

Firebase Download URLs

getDownloadUrl()

A Firebase download URL is a long-lived, publicly accessible URL that is used to access a file from Cloud Storage. The download URL contains a download token which acts as a security measure to restrict access only to those who possess the token.

The download token is created automatically whenever a file is uploaded to Cloud Storage for Firebase. It's a random UUIDv4, which makes the URL hard to guess.

To retrieve a download URL, your client app needs to call the getDownloadUrl() method on the file reference.

const storage = firebase.storage();

storage.ref('image.jpg').getDownloadURL()
  .then((url) => {
    // Do something with the URL ...
  })

The resulting download URL will look like this:

https://firebasestorage.googleapis.com/v0/b/<projectId>.appspot.com/o/image.jpg?alt=media&token=<token>

Only authorized users can call getDownloadURL() (more on this below). But the method will return the same download URL for every invocation because there's only one (long-lived) token stored per file. This means that anyone who gets their hands on the download URL will be able to access the file, whether they're authorized or not! In order words, retrieving the download URL is a restricted operation, but the download URL itself is public.

To revoke a download URL, you need to manually revoke the download token in the Firebase Console. You can't do this on your client app.

Firebase download tokens in the Firebase Console

Restricting access to files

In order to keep files private, developers are encouraged to restrict read access by defining Firebase Storage security rules. Only authorized users (users with read access) are able to call getDownloadUrl(). Here's an example security rule:

match /path/to/{file} {
  // Deny reads
  allow read: if false;

  // Or, allow reads by authenticated users
  allow read: if request.auth != null;
}

As mentioned earlier, even if calls to getDownloadUrl() are restricted, the actual download URLs are public (although hard to guess because of the random UUIDv4). So if anyone shares the result of getDownloadUrl(), the file won't be very secret anymore!

Creating download URLs

The Firebase client SDK allows you to retrieve, but not create, download URLs.

Technically, download tokens are stored as custom metadata on the storage object. If you browse to a file in the Google Cloud Console (not the Firebase Console) you'll be able to see the download token stored as firebaseStorageDownloadTokens.

Firebase download tokens in the Google Cloud Console

This means that you can manipulate download tokens by updating the value of firebaseStorageDownloadTokens. You can do this with the Cloud Storage SDK, which is bundled with the Firebase Admin SDK and accessible by calling admin.storage().

For example, you can upload a file from a Firebase Function and set a custom download token:

const admin = require("firebase-admin");
const bucket = admin.storage().bucket();
const uuid = require("uuid-v4");

// ... inside your function:

await bucket.upload("file.txt", {
  destination: "uploads/file.txt",
  metadata: {
    cacheControl: "max-age=31536000",

    // "custom" metadata:
    metadata: {
      firebaseStorageDownloadTokens: uuid(), // Can technically be anything you want
    },
  },
})

You can also update an existing file's download token:

const admin = require("firebase-admin");
const bucket = admin.storage().bucket();
const uuid = require("uuid-v4");

// ... inside your function:

const file = bucket.file("uploads/file.txt");

await file.setMetadata({
  metadata: {
    // Update the download token:
    firebaseStorageDownloadTokens: uuid(),
    // Or delete it:
    firebaseStorageDownloadTokens: null,
  },
})

Now, calling the client-side method getDownloadUrl() on the file reference will give you a download URL that contains your newly created token. Note that if you delete the token (set it to null), calling getDownloadUrl() will create and store a new one; Firebase seems to always want at least one token for every file.

NB: This is not a documented feature! Firebase doesn't expose the concept of download tokens in its public SDKs or APIs, so manipulating tokens this way feels a bit "hacky". How Firebase deals with download tokens may be subject to change. On a positive note, official Firebase products like Firebase Extensions also manipulate download tokens, and the approach has been suggested by Google employees.

There's no getDownloadUrl() in the Node SDK

The concept of download tokens is specific to Cloud Storage for Firebase. Google Cloud Storage itself, which Firebase relies on, doesn't know the meaning of firebaseStorageDownloadTokens. But the field can be edited like any other custom metadata.

The Firebase Node SDKs bundles the Cloud Storage SDK which isn't aware of any Firebase-specific functionality. That's why you can't call getDownloadUrl() on the Node SDK. But you can construct a download URL manually:

const createPersistentDownloadUrl = (bucket, pathToFile, downloadToken) => {
  return `https://firebasestorage.googleapis.com/v0/b/${bucket}/o/${encodeURIComponent(
    pathToFile
  )}?alt=media&token=${downloadToken}`;
};

Again, note that this is not a documented feature.

getDownloadUrl() incurs network traffic

A call to getDownloadUrl() utilizes some Google Cloud resources. Specifically, it's a "Class B" operation (check the Google Cloud pricing page) that triggers a bit of data transfer. This is why the method is asynchronous.

If you don't want your client app to call getDownloadUrl() and wait for the result, you can store download URLs in a database like Firestore. This works because the URL doesn't change unless you revoke the token.

Summary

  • Every file gets a download token during upload.
  • If you overwrite a file, a new download token is generated.
  • The download token, and thus the download URL, is long-lived and public.
  • Download tokens can be revoked in the Firebase Console, which replaces it with a new one.
  • The token is stored as custom metadata, firebaseStorageDownloadTokens, and you can write to this field from the Google Cloud Console or using the Storage SDKs.
  • This concept of download URLs is specific to Firebase. Google Cloud Storage itself doesn't know the meaning of firebaseStorageDownloadTokens, but it let's you edit it like any other custom metadata field.

Signed URLs

Instead of using Firebase download URLs (getDownloadUrl()), which are long-lived and public, signed URLs allow you to create short-lived URLs that give access to files for a specific amount of time. For example, to grant a user permission to download a file, you can create a signed URL that expires in 2 minutes. Even if the user shares or saves this URL, it won't be accessible after its expiration.

Here's an example of how you can create a signed URL in a Firebase Function:

const admin = require("firebase-admin");
const bucket = admin.storage().bucket();

// ... inside your function:

const urlOptions = {
  version: "v4",
  action: "read",
  expires: Date.now() + 1000 * 60 * 2, // 2 minutes
}

const [url] = await bucket
  .file("uploads/file.txt")
  .getSignedUrl(urlOptions);

// Return `url` to the user.
// It will look something like:
// https://storage.googleapis.com/project-id.appspot.com/uploads/file.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=loooooong string ...

If your strategy is to keep your files private and only give access using signed URLs, you don't want anyone to retrieve the long-lived Firebase download URL by calling getDownloadUrl(). This can be restricted by denying reads in the Storage security rules:

match /path/to/{file} {
  // Deny reads
  allow read: if false;
}

Be careful when signing URLs with Firebase Storage!

Here's a big caveat when signing URLs to Firebase Storage objects.

As mentioned, the file's download token is created automatically during upload. This applies both to the client-side Firebase SDK and the Firebase Console. And you can restrict calls to getDownloadUrl() by defining security rules, which means the token will stay secret and the file private.

However, metadata (even custom metadata) is exposed through HTTP headers when a file is downloaded. As the Cloud Storage documentation states:

"x-goog-meta- headers are stored with an object and are always returned in a response header when you do a GET or HEAD request on an object."

We can test this on a signed URL:

GET
https://storage.googleapis.com/project-id.appspot.com/file.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=loooooong string ...

Response headers:
x-goog-meta-firebasestoragedownloadtokens: 95ded11d-952f-4d26-b730-b920f0881542

This means that someone can use the download token and construct a Firebase download URL and obtain unrestricted access to the file:

https://firebasestorage.googleapis.com/v0/b/project-id.appspot.com/o/uploads/file.txt?alt=media&token=95ded11d-952f-4d26-b730-b920f0881542

The only way to remove the download token from the header is to remove the firebaseStorageDownloadTokens field from the custom metadata. But you can't set the field to null when uploading files through the client SDK. It would result in the error "Not allowed to set custom metadata for firebaseStorageDownloadTokens".

You also can't remove (only renew) the token in the Firebase Console. Only the Google Cloud Console allows you to edit or remove the field, but this would be a manual operation.

The only option left is to remove the token server-side, e.g. in a Firebase Function triggered by file uploads:

const functions = require("firebase-functions");
const admin = require("firebase-admin");
const bucket = admin.storage().bucket();

exports.removeDownloadToken = functions.storage.object().onFinalize((object) => {
    const file = bucket.file(object.name);

    return remoteFile.setMetadata({
      // Metadata is merged, so this won't delete other existing metadata
      metadata: {
        // Delete the download token
        firebaseStorageDownloadTokens: null,
      },
    })
});

Another option is to upload files using other Google Cloud tools (not Firebase).

As long as calls to getDownloadUrl() are disallowed in the security rules, the download token won't be regenerated.

Public URLs

If you don't require any security checks, you can make your files entirely public. Using the Google Cloud Storage SDK, you can call makePublic() on a storage object.

Make an existing file public in a Firebase Function:

const functions = require("firebase-functions");
const admin = require("firebase-admin");
const bucket = admin.storage().bucket();

// ... inside your function:

const [file] = await bucket.file("uploads/file.txt").makePublic();

const [metadata] = file.getMetadata();
const url = metadata.mediaLink;

Or, uploading a public file:

const functions = require("firebase-functions");
const admin = require("firebase-admin");
const bucket = admin.storage().bucket();

// ... inside your function:

const [file] = await bucket.upload("file.txt", {
  destination: "uploads/file.txt",
  public: true, // Alias for predefinedAcl = 'publicRead'
});

const [metadata] = file.getMetadata();
const url = metadata.mediaLink;

This will let anyone access the files with the following URL:

https://storage.googleapis.com/project-id.appspot.com/file.txt

However, Firebase doesn't let you access files through their API without a download token, even if the download token has been removed from the file; a Firebase download URL without a token will respond with an error. Notice how this URL lacks a token parameter in the query string:

GET https://firebasestorage.googleapis.com/v0/b/project-id.appspot.com/o/file.txt?alt=media

Response:
{
  "error": {
    "code": 403,
    "message": "Permission denied. Could not perform this operation"
  }
}

If this operation succeeded, it would be impossible to store completely private files in Firebase Storage.