Most teams we audit have thought about Firestore rules and not about Storage rules. The Storage ruleset is usually one of two things: the console default that expires after 30 days, or a single allow read, write: if request.auth != null that has been in the repo since the prototype.
That second one means any signed-up user can upload a 2 GB file to any path in your bucket, at any rate, and then hand the URL to anyone. Firestore rules mistakes leak data. Storage rules mistakes leak data and bandwidth, and bandwidth is the more expensive half.
Here is what an actual production ruleset has to do, and the two things it cannot do.
Scope the path first
Storage paths are strings, not documents, so the match block is doing the ownership work. Pin the uploader's UID into the path and then compare it:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /users/{userId}/avatar/{fileName} {
allow read: if request.auth != null;
allow write: if request.auth != null
&& request.auth.uid == userId
&& isImage()
&& request.resource.size < 2 * 1024 * 1024;
}
match /orgs/{orgId}/uploads/{fileName} {
allow read, write: if request.auth.token.orgId == orgId;
}
}
}
request.resource describes the object being uploaded: size in bytes, contentType, metadata. resource describes the object already there. A missing size check is the difference between a 2 MB avatar and whatever the client felt like sending.
One detail that bites: in rules_version = '2', {fileName} matches a single path segment. A client uploading to users/alice/avatar/2024/pic.jpg falls through to no match and is denied — correct, but confusing during debugging. Use {allPaths=**} deliberately if you need a subtree, and only under a segment you have already constrained by UID.
Content-type is a claim, not a fact
function isImage() {
return request.resource.contentType.matches('image/(png|jpeg|webp)');
}
This check is worth having and it is not security. contentType is whatever the client set in the upload request. A script can send a PHP file, an HTML page, or a 10 MB polyglot and label it image/png. Rules cannot read the bytes of the object, so this is the ceiling of what rules alone can verify.
The HTML case is the one that actually hurts: a user-uploaded text/html served from your bucket origin is a stored-XSS vector if anything of yours shares that origin's trust. Deny text/html explicitly, and set Content-Disposition: attachment on anything user-supplied that you don't need rendered inline.
If uploads matter, verify them after the fact with a Gen 2 trigger:
import { onObjectFinalized } from 'firebase-functions/v2/storage';
import { getStorage } from 'firebase-admin/storage';
export const verifyUpload = onObjectFinalized(
{ bucket: 'my-app-quarantine', memory: '512MiB' },
async (event) => {
const { name, contentType, size } = event.data;
const file = getStorage().bucket(event.data.bucket).file(name);
const [head] = await file.download({ start: 0, end: 11 });
const png = head[0] === 0x89 && head[1] === 0x50;
const jpeg = head[0] === 0xff && head[1] === 0xd8;
if (!png && !jpeg) {
await file.delete();
return;
}
await file.copy(getStorage().bucket('my-app-public').file(name));
await file.delete();
}
);
Two-bucket quarantine is the pattern: clients can only write to the bucket nothing serves from, and objects arrive in the serving bucket only by way of code that has looked at them. It costs you one function invocation and a copy per upload. For a product where users upload files to each other, that is cheap insurance; for internal-only tooling it is probably over-engineering, and rules plus a size cap will do.
Note the trigger is at-least-once. onObjectFinalized can fire twice for the same object, and your handler must tolerate that — here it does, because the second run finds the file already gone and the delete() throws a 404 you should catch rather than retry forever.
getDownloadURL hands out a key that ignores your rules
This is the part that surprises people. getDownloadURL() returns something like:
https://firebasestorage.googleapis.com/v0/b/APP.appspot.com/o/users%2Falice%2Favatar%2Fpic.jpg?alt=media&token=8f1c...
That token is a download token stored in the object's metadata. Anyone holding the URL can fetch the object — no sign-in, no App Check, no rules evaluation. Your allow read guarded who could obtain the URL. It does not guard who can use it. The token does not expire.
So: if a URL ends up in an email, a support ticket, a Slack export, or a client-side bundle, the object is public until you act. Acting means rotating the token, which you do by clearing it from metadata with the Admin SDK:
await getStorage().bucket().file(path).setMetadata({
metadata: { firebaseStorageDownloadTokens: '' },
});
That invalidates every URL previously issued for the object, including the ones you still wanted. There is no partial revocation.
For anything genuinely sensitive — invoices, identity documents, private exports — do not issue download URLs at all. Issue short-lived signed URLs from a callable function that performs the authorization check itself:
const [url] = await getStorage().bucket().file(path).getSignedUrl({
version: 'v4',
action: 'read',
expires: Date.now() + 5 * 60 * 1000,
});
Five minutes, per request, per user, auditable in your own logs. The rules still protect direct SDK access; the signed URL is what you hand to the browser.
The line this puts on your invoice
Storage-at-rest is not the expensive part. Standard storage runs about $0.026 per GB-month, so 500 GB of user files is roughly $13. Egress is the part that moves.
Downloads out of Cloud Storage to the internet bill around $0.12 per GB. An app serving a 1.5 MB profile image on every page view, 40,000 views a day, is 60 GB a day — 1.8 TB a month, about $216, for images that should have been 40 KB and cached.
Three fixes, in order of effect:
- Resize on upload. The finalize trigger that verifies the file can also write a 200 px thumbnail. Serving 40 KB instead of 1.5 MB is a 97% cut to the same line.
- Put a CDN in front. Objects fetched through Firebase Hosting or Cloud CDN bill cache egress, and repeat views bill nothing at origin. Direct
firebasestorage.googleapis.comlinks have no CDN in front of them — every view is a fresh origin read. - Set
Cache-Control. Default metadata on uploaded objects isCache-Control: private, which suppresses caching everywhere. For immutable, content-hashed assets, setpublic, max-age=31536000at upload time and the problem stops recurring.
There is no per-download quota you can set on a bucket, which means abuse is bounded only by rules and App Check. Enforce App Check on Storage once your clients are attesting; it is the only thing that stops a scraper from pulling your entire public prefix at line rate with a valid user account.
Test it like the Firestore rules
The emulator supports Storage rules with the same harness:
const alice = env.authenticatedContext('alice').storage();
await assertFails(
uploadBytes(ref(alice, 'users/bob/avatar/pic.png'), bytes, { contentType: 'image/png' })
);
await assertFails(
uploadBytes(ref(alice, 'users/alice/avatar/evil.html'), bytes, { contentType: 'text/html' })
);
await assertFails(
uploadBytes(ref(alice, 'users/alice/avatar/big.png'), threeMegabytes, { contentType: 'image/png' })
);
Three tests: wrong owner, wrong type, too big. That is the whole access matrix for most upload paths, it runs in the same CI job as your Firestore rules suite, and it is three tests more than most Storage rulesets have.
The summary we give clients: rules decide who may write and roughly what shape; a trigger decides whether the bytes are what they claimed; signed URLs decide who may read and for how long. Skip the middle layer if your uploads are low-stakes. Do not skip the third one and then wonder why an invoice PDF showed up in a search index.