Storage Authorization
Collections are protected by row-level security; a bucket has no per-object equivalent, so storage is authorized here. Storage Configuration covers the backends themselves.
Policies
Section titled “Policies”The declarative form. A list of path patterns, read without running anything:
storagePolicies: [ { path: "public/**", operations: ["read"], allow: "public" }, { path: "users/:uid/**", allow: ({ params, user }) => user?.uid === params.uid }]A key matched by no policy is refused, unless a storageAuthorize hook allows it: the hook is asked only when no policy matched the key for that operation, and neither can narrow the other. A policy that matched and did not grant is final. Every widening is an explicit line, and a mistake denies rather than grants.
Patterns match by segment, never by substring — public/** does not match
publicity/secret.png:
| Pattern | Matches |
|---|---|
avatars/logo.png |
that key exactly |
users/*/avatar.png |
exactly one segment where * is |
users/:uid/** |
one captured segment, then the rest — including nothing |
** is only legal as the final segment. :name captures one segment and never
spans a /; captures arrive as params on the predicate.
allow is "public" (anyone the route lets in), "authenticated" (any caller
with a uid), or a predicate receiving the captured params, the user, the
operation and the bucket.
A policy runs after the route’s authentication, so "public" alone does not open
a path to anonymous callers. With requireAuth on, they reach reads of public/
keys only, or every read with storagePublicRead; /upload, /list, deletes and
resumable uploads always need a caller. Elsewhere the answer is 401.
operations defaults to all four — read, write, delete, list.
Policies satisfy the production boot guard on their own, and a malformed pattern fails the boot rather than the first upload.
The hook
Section titled “The hook”requireAuth and publicRead are global switches: they decide whether a caller must be signed in, not what that caller may touch. Without an authorization hook, any authenticated user can read any key they can name — the only thing separating two tenants’ files is key unguessability, which is not an access-control model. Worse, they can GET /storage/list?prefix= first, so the keys do not even have to be guessed.
storageAuthorize is the storage analogue of a collection’s security rules, and runs after authentication on every storage route:
await initializeRebaseBackend({ storage: { type: "s3", bucket: "app-files", /* ... */ }, storageAuthorize: async ({ key, bucket, operation, user }) => { if (!user) return false; // Keys are laid out as `{teamId}/{docId}/...` const [teamId] = key.split("/"); return isTeamMember(user.uid, teamId); }});| Field | Description |
|---|---|
key |
Object key, bucket prefix stripped and traversal sanitized |
bucket |
Resolved bucket ("default" when unspecified) |
operation |
"read", "write", "delete" or "list" |
user |
{ uid, email?, roles? }, or null where the route allows anonymous access |
storageId |
The named backend, when the request targeted one — its canonical key (storageId=%20media is "media"). Absent for the default source, however the request named it (no parameter, empty, or (default)) |
data |
Trusted, RLS-bypassing read access — data.collection(slug).find(query) / .findById(id). Ownership lives in a row, not in a key prefix, so the hook needs a reader to answer “who owns this object?”. It bypasses row-level security deliberately: this hook is the authorization decision, and making it through a reader already narrowed by the caller’s own permissions would be circular. Read-only by design. |
Return false to deny with a 403. Throwing also denies — an ownership lookup that fails does not fall open.
Worth knowing:
- The metadata route is where read access is really decided. It mints the short-lived path-scoped download token that the file route trusts, so the hook gates it there. Two reads skip the hook, by design. One carrying such a token: the hook approved it when the token was minted, for that path and source only, so a hook that would now refuse (a membership revoked since) does not stop it before the token runs out —
storageDownloadTokenTtl, 300 seconds by default. And a read of a key underpublic/by a caller with no credential: the prefix is the declaration that anyone may read it, anonymous visitors and CDNs included. A signed-in caller’s read of it is still asked about, but an anonymous one is not, so a hook cannot make apublic/key private; store the file outsidepublic/instead. Only those two reads skip it: a user your auth adapter happens to callpublicis asked about like anyone else. - One bucket, one name. On S3 and GCS the hook’s
bucketis always"default", also when a request names the provider’s bucket, which reaches the same objects. - A path is public when the key it names is. The
public/prefix is tested on the canonical key the route serves — after a leadingdefault/bucket segment is read off and//,./and a leading/are folded away — never on the URL as written./api/storage/file/notes://public/x.txtnames the private keynotes:/public/x.txt, and the keydefault/public/x.txt(reached as/api/storage/file/default/default/public/x.txt) lies in a folder calleddefault; neither is public. In a hook, test thekeyyou are handed withisPublicStorageKeyfrom@rebasepro/types—isPublicStoragePathreads a client-side path or URL, and reads both of those as public. listis gated on the prefix. Listing is how you discover keys nobody told you about.- Resumable (TUS) uploads are gated at create time, so a denied upload leaves no temp file behind.
- Omitting the hook (and
storagePolicies) leaves no per-object check, so production refuses to boot unlessstoragePublicReadorstorageInsecureAllowAnyAuthenticatedis set.
API keys and tokens
Section titled “API keys and tokens”A signed-in person holds every storage scope, so the hook and the policies above
decide for them. An API key also needs the storage
scope for the operation, on the source the request names: storage:read for
downloads, metadata and listings, storage:write for uploads, folders and every
step of a resumable upload, storage:delete for deletes. The target is the
source id, and the default source’s is (default): storage:read:(default),
storage:write:avatars. Without it the answer is 403 SCOPE_MISSING. With it,
the hook still runs, as whoever the key acts as.
Next Steps
Section titled “Next Steps”- Storage Configuration — backends, endpoints and limits
- API Keys — the scopes a key carries