Cloud Storage
Fireemu serves Cloud Storage for Firebase at 127.0.0.1:9199, with both API dialects:
- the Firebase Storage protocol the client SDKs use, including resumable uploads and download tokens;
- the Cloud Storage JSON API the Admin SDK and the Google Cloud client libraries use, including copy and rewrite.
Both dialects share one store, so an object uploaded through one is visible through the other.
Production behavior
Section titled “Production behavior”Under strict, generation and metageneration preconditions and declared checksums are honored as production honors them. The official emulator verifies no checksum.
Storage Security Rules are read from the storage.rules file named in firebase.json, or storage.rules in fireemu.toml. A Storage rule can read Firestore documents with firestore.get() and firestore.exists(). Under strict, a project without rules refuses every end-user request with production’s 400 “bucket has not been set up” answer. See Security Rules.
Under strict the Storage emulator also refuses what production refuses and the official emulator accepts (a bucket that does not exist is the exception, see below):
- A public URL for a private object.
GET /{bucket}/{object}, which is whatfile.publicUrl()builds, answers 403AccessDeniedunless the request carries a non-emptyBearerAuthorizationheader, comes from a Google client library (these sendx-goog-api-client, and the Go library, so the Firebase Admin Go SDK, reads through this URL with no credential whileSTORAGE_EMULATOR_HOSTis set; the Python, Java and Node libraries read through the JSON API and are never refused), is a signed URL that has not expired, or the object is public. An object is public when application code made it so (file.makePublic(),predefinedAcl=publicReadon an upload, patch, copy (destinationPredefinedAcl) or rewrite, anallUsersREADERentry in theaclof an uploaded or patched resource, orPOST .../acl), for that generation of the object only: an overwrite is private again.file.makePrivate()(predefinedAcl=private), a patchedaclwithout that entry andDELETE .../acl/allUsersend it. Anaclin the metadata that starts a resumable upload is not read. A bucket whose IAM grantsallUsersthe object viewer role, which production sets in the console, is listed instorage.publicBuckets. The 403 names both ways. Firebase download-token URLs are not affected. - An expired signed URL. A V4 or V2 signed URL past its lifetime answers 400
ExpiredToken, and a V4 lifetime over seven days or an unreadableX-Goog-Dateanswers 400. The signature itself is not verified, because that needs the signing service account’s key; any signature is accepted. - A form upload without a signed policy.
POST /{bucket}with a multipart form needs apolicyand a signature field. The policy’s expiry and conditions (bucket,eq,starts-with,content-length-range) are enforced; the signature is not verified. - A Firebase ID token as an OAuth bearer on the JSON API: 401
Invalid Credentials. Any other credential, or none, is served as the owner, because a local run cannot judge an access token. - An ACL entry Cloud Storage does not know (400). Only
allUserswith roleREADERhas an effect. - A bucket name Cloud Storage never accepts (for example
ab,goog-xor1.2.3.4): 400Invalid bucket nameon the JSON API, 400InvalidBucketNameon the XML routes and 404 on the Firebase routes, unless a bucket of that name exists (imported or declared).
The emulator profile keeps the official emulator’s behavior for all of these.
Listing a prefix with single-segment attachment rules
Section titled “Listing a prefix with single-segment attachment rules”In the strict profile, list authorization proves access to the possible entries under the requested prefix. With delimiter=/, each entry is one immediate child; without a delimiter, entries can have arbitrary depth. Authorization does not depend on whether the prefix is empty or on which objects happen to exist.
For prefix=teams/T/requests/R/&delimiter=/, a rule matching /teams/{team}/requests/{id}/{attachment} with allow list: if member(team) can authorize the listing. The team and request ID are fixed by the prefix, while the attachment name is unknown. A condition requiring a particular attachment name does not authorize a directory listing merely because all current objects match that name.
A targeted production comparison on 2026-10-16 used authenticated UID-to-team membership, separate upload/get controls and two recordings. Current strict behavior matches the following observed outcomes. The ancestor prefix is teams/T/requests/; the request prefix is teams/T/requests/R/.
Rules granting list to the member |
Prefix | Delimiter | Production and current strict |
Before this fix |
|---|---|---|---|---|
| Single attachment capture | Request | / |
200 | 403 |
| Single attachment capture | Request | Omitted | 403 | 403 |
| Same rules plus explicit parent match | Request | / |
200 | 200 |
| Same rules plus explicit parent match | Request | Omitted | 403 | 200 |
| Same rules plus explicit parent match | Ancestor | / |
200 | 403 |
Same rules plus recursive list match |
Request | / or omitted |
200 | 200 |
Same rules plus recursive list match |
Ancestor | / |
200 | 403 |
Empty request prefixes have the same authorization decisions as populated ones. Non-member, anonymous and other-team request listings remain denied. The emulator profile retains its existing concrete-prefix authorization. Non-slash delimiters use a conservative arbitrary-depth proof; the production comparison covered / and an omitted delimiter, not every custom delimiter or resource-dependent condition. The Firebase granular operations documentation provides further background.
An explicit parent rule is no longer needed as a workaround for the single-capture SDK request above. Adding one can change production permissions:
match /teams/{team}/requests/{id} { allow list: if member(team);}In the measured rules, this also permits listing the ancestor’s child prefixes. It does not authorize delimiter-free recursive enumeration of the request’s attachments. Keep attachment get, create, update and delete conditions unchanged. Do not replace them with a broad recursive allow read: read includes both get and list. Listing permission exposes names under the prefix even when a particular object’s content cannot be read; it does not grant content access or filter the list to objects with get permission.
Buckets that do not exist
Section titled “Buckets that do not exist”Production answers 404 The specified bucket does not exist. for an object operation on a bucket nobody created. The Storage emulator serves it instead, and creates the bucket on the first write, so a test needs no setup. Under strict it prints one note: line on standard error the first time an object is written to a bucket that is not a default bucket of the project (<project>.appspot.com or <project>.firebasestorage.app), a firebase.json storage target, imported data, a bucket a deployed Storage trigger names, or listed in the configuration. A typo in a bucket name is visible that way. To silence the note for a bucket that exists in production, list it:
schemaVersion = 1
[storage]buckets = ["uploads-bucket", "exports.example.com"]publicBuckets = ["public-assets.example.com"]{ "schemaVersion": 1, "storage": { "buckets": [ "uploads-bucket", "exports.example.com" ], "publicBuckets": [ "public-assets.example.com" ] }}Each name must be one Cloud Storage accepts. storage.publicBuckets lists buckets whose objects the public URL serves without a credential, and also counts as declaring them. storage.buckets and storage.publicBuckets are ignored by the emulator profile.
A production project has one default bucket: <project>.firebasestorage.app if Storage was set up after 2024-10-30, <project>.appspot.com before. A local run cannot know which, so both exist by default. To catch an app configuration copied from the other kind of project, set storage.defaultBucket to "firebasestorage.app" or "appspot.com" (default "both"): the other name then counts as an undeclared bucket, so its first write prints the note and GET /storage/v1/b/{bucket} answers 404.
Limit the stored bytes
Section titled “Limit the stored bytes”The Storage emulator keeps every object in memory, and by default nothing bounds how much. To cap it, set storage.maxStoredBytes. This limit is local to Fireemu; production has none:
schemaVersion = 1
[storage]maxStoredBytes = 1073741824{ "schemaVersion": 1, "storage": { "maxStoredBytes": 1073741824 }}- An upload, a resumable upload’s final request or a copy that would grow the total past the limit is refused with HTTP 402 and the message
storage.maxStoredBytes limit exceeded. The Firebase client SDKs report it at once asstorage/quota-exceeded. - A write that does not grow the total, such as an equal or smaller replacement, always passes.
- A refused write changes nothing. A resumable upload stays open, so you can send the same final chunk again after making room.
fireemu doctor --connect <control URL> reports the stored bytes against this limit.
Not supported
Section titled “Not supported”Verifying the signature of a signed URL or a POST policy, object ACLs other than allUsers read, IAM and bucket-level public access, object composition (compose) and notification configuration are not supported. Object versioning is served on the JSON API: PATCH /storage/v1/b/{bucket} with {"versioning":{"enabled":true}} turns it on, as in production.