Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Isolate each tenant's files in a multi-tenant application

Keep two tenants in one bucket apart with server-derived key prefixes, and see exactly what the gateway returns when one tenant reaches for the other's files.

Derive each tenant’s key prefix from the session on the server, and return it from the gateway’s authorize hook as keyPrefix. The gateway prepends it to every key the browser sends, refuses keys that try to climb out with .., and strips it from everything it returns. A tenant has no way to name another tenant’s object, and the table below shows what happens when one tries.

The catch is where that boundary lives. It’s in your application, not in the bucket. To the storage provider, orgs/acme/ is the start of a string and nothing more, and anything that holds the bucket credentials, a signed URL, or an upload token goes around the gateway entirely. When a shared bucket isn’t enough covers separate buckets and provider-side permissions.

Before you start

  • A Files SDK gateway in your app. This guide reuses the Next.js route and lib/files.ts from Build a Next.js file uploader with Cloudflare R2. The policy works the same with any adapter and any framework binding.
  • A session that carries a stable tenant ID. A tenant can be a user or an organization; this guide uses organizations, and the code is identical with session.user.id.
  • Written against files-sdk 3.0 and Next.js 16.4. The results below come from running the gateway in Bun against the memory adapter, and the signing-adapter differences from a local MinIO server.

Derive the prefix from the session

Put the policy in its own module so the route and a test can share it:

import { FilesError } from "files-sdk";
import type { Authorize, FilesOperation } from "files-sdk/api";

export interface Session {
  user: { id: string; orgId: string };
}

// Allow only the verbs your UI calls. This guide allows every core verb so
// the isolation table below covers all of them.
const ALLOWED = new Set<FilesOperation>([
  "capabilities",
  "list",
  "search",
  "head",
  "exists",
  "download",
  "url",
  "upload",
  "delete",
  "copy",
  "move",
  "signedUploadUrl",
]);

export function tenantPolicy(
  getSession: (headers: Headers) => Promise<Session | null>
): Authorize {
  return async ({ operation, req }) => {
    const session = await getSession(req.headers);
    if (!session) {
      throw new FilesError("Unauthorized", "Sign in to manage files");
    }
    if (!ALLOWED.has(operation)) {
      throw new FilesError("ReadOnly", `${operation} is not allowed`);
    }
    return {
      keyPrefix: `orgs/${session.user.orgId}/`,
      maxExpiresIn: 300,
    };
  };
}
import { createFilesRouter } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";

import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
import { tenantPolicy } from "@/lib/files-policy";

const router = createFilesRouter({
  files,
  authorize: tenantPolicy(getSession),
});

export const { GET, POST, PUT } = createRouteHandler(router);

getSession stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check). This guide assumes it takes request headers and returns { user: { id, orgId } } or null.

Three rules keep the prefix trustworthy:

  • Only the session decides it. Never build the prefix from a query parameter, header, or body field the browser controls. authorize also receives the client’s key; use it for per-key rules, never to choose the prefix.
  • Use an immutable, opaque ID. If the prefix is a slug or display name a tenant can change, a tenant that renames itself to a former tenant’s slug inherits that tenant’s files. Error messages also echo the full storage key (unsafe key: orgs/acme/../…), so a tenant can see its own prefix. Don’t put anything private, like an email address, in it.
  • The trailing slash is handled for you. The gateway normalizes orgs/acme to orgs/acme/ before using it, so acme never matches acme-co. The tenants below were picked so that one name is a string prefix of the other.

What one tenant can reach

The bucket holds orgs/acme/report.pdf and orgs/acme-co/secret.txt. A signed-in acme user sends each request straight to the gateway (the same JSON and query strings useFiles sends, but hand-built, the way an attacker would) and tries two ways to reach acme-co’s file: a traversal key and the full storage key.

Operation Traversal: ../acme-co/secret.txt Full key: orgs/acme-co/secret.txt What the full key touched
list (as prefix) 422 200, items: [] listed orgs/acme/orgs/acme-co/
search 422 as prefix; 200, no matches as a glob 200, no matches as a glob or regex walked orgs/acme/ only
head 422 404 looked up orgs/acme/orgs/acme-co/secret.txt
exists 422 200, exists: false the same lookup
download 422, also when sent as %2E%2E%2F… 404 the same lookup
url 422 404 the same lookup
upload with a key 422 200 wrote orgs/acme/orgs/acme-co/secret.txt
upload without a key a file named ../../acme-co/secret.txt was stored as <uuid>.txt not applicable wrote orgs/acme/<uuid>.txt
delete 422 200 deleted orgs/acme/orgs/acme-co/secret.txt, which didn’t exist
delete with a key list 422 for the whole batch if any key traverses 200, both keys reported in results the same, per key
copy 422 as source or destination source: 404; destination: 200 destination: wrote orgs/acme/orgs/acme-co/planted.pdf
move 422 as source or destination source: 404; destination: 200 destination: moved acme’s file to orgs/acme/orgs/acme-co/report.pdf
signedUploadUrl 422 200 signed a PUT for orgs/acme/orgs/acme-co/secret.txt, expiring in 300 seconds

Every request without a session got 401. After the run, orgs/acme-co/ held exactly what it started with.

Three kinds of result show up in that table:

  • 422 Validation means the gateway refused the key before calling storage. It prepends the prefix first and then rejects the result if it contains a . or .. segment or a NUL byte, so ../acme-co/secret.txt becomes orgs/acme/../acme-co/secret.txt and stops there. Query strings are decoded before the check, so %2E%2E%2F is caught too. A double-encoded %252E%252E%252F or a backslash path (..\acme-co\secret.txt) isn’t a traversal in object storage at all; both came back 404 as literal keys under orgs/acme/.
  • 404, empty lists, and exists: false mean the full storage key was treated as a relative key. orgs/acme-co/secret.txt becomes orgs/acme/orgs/acme-co/secret.txt, which doesn’t exist.
  • 200 on a write means the write happened inside acme’s own prefix. That includes delete: deleting a key that doesn’t exist succeeds, on the memory adapter and against a local MinIO server alike. A 200 from a cross-tenant delete doesn’t mean anything was deleted, which is why the last column comes from listing the bucket afterwards, not from the responses.

Keyless uploads can’t be steered at all. The server mints the key from the tenant prefix, a random UUID, and the file’s extension; the client’s file name contributes nothing else. Tampering with the key in the complete step changes nothing either, because the signed upload token carries the real key.

On a signing adapter, two rows look different. The memory adapter can’t sign URLs, so url and download look the object up and answer 404. S3, R2, and MinIO sign without a lookup: against a local MinIO server, url returned 200 with a signed URL and download returned a 302, both pointing at orgs/acme/orgs/acme-co/secret.txt. Fetching that URL returned 404 NoSuchKey. Either way, the URL can only point inside the caller’s prefix.

Keep the check in your test suite

The table comes from a script like this one. It mounts the real policy over the memory adapter, reads the session from a test header, and asserts on the status codes, on an upload token replayed across tenants, and on the bucket’s contents afterwards:

import assert from "node:assert/strict";

import { createFiles } from "files-sdk";
import { createFilesRouter } from "files-sdk/api";
import { memory } from "files-sdk/memory";

import { tenantPolicy } from "../lib/files-policy";

const files = createFiles({ adapter: memory() });
await files.upload("orgs/acme/report.pdf", "acme report");
await files.upload("orgs/acme-co/secret.txt", "acme-co only");

// The production policy, with the session read from a test header.
const router = createFilesRouter({
  files,
  secret: "test-only-secret",
  authorize: tenantPolicy(async (headers) => {
    const orgId = headers.get("x-test-org");
    return orgId ? { user: { id: "test-user", orgId } } : null;
  }),
});

const endpoint = "https://app.example.com/api/files";
const as = (orgId: string | null) => (url: string, init: RequestInit) =>
  router.handle(
    new Request(url, {
      ...init,
      headers: { ...(orgId && { "x-test-org": orgId }), ...init.headers },
    })
  );
const post = (body: object): RequestInit => ({
  body: JSON.stringify(body),
  headers: { "content-type": "application/json" },
  method: "POST",
});
const acme = as("acme");
const json = (body: object) => acme(endpoint, post(body));
const query = (params: Record<string, string>) =>
  `${endpoint}?${new URLSearchParams(params)}`;

const traversal = "../acme-co/secret.txt";
const fullKey = "orgs/acme-co/secret.txt";
const put: RequestInit = { body: "overwritten", method: "PUT" };

// [label, request from an acme user, expected status]
const cases: [string, () => Promise<Response>, number][] = [
  ["list", () => json({ op: "list", prefix: "../acme-co/" }), 422],
  ["head", () => json({ op: "head", key: traversal }), 422],
  ["head full key", () => json({ op: "head", key: fullKey }), 404],
  ["download", () => acme(query({ op: "download", key: traversal }), {}), 422],
  ["upload", () => acme(query({ op: "upload", key: traversal }), put), 422],
  ["delete", () => json({ op: "delete", key: traversal }), 422],
  ["copy out", () => json({ op: "copy", from: traversal, to: "x.txt" }), 422],
  [
    "copy in",
    () => json({ op: "copy", from: "report.pdf", to: traversal }),
    422,
  ],
  [
    "move full key",
    () => json({ op: "move", from: fullKey, to: "x.txt" }),
    404,
  ],
  [
    "sign upload",
    () => json({ op: "signed-upload-url", key: traversal, expiresIn: 60 }),
    422,
  ],
  ["no session", () => as(null)(endpoint, post({ op: "list" })), 401],
];

for (const [label, send, expected] of cases) {
  const res = await send();
  assert.equal(res.status, expected, `${label}: ${await res.text()}`);
}

// An upload token minted for acme-co can't be completed from acme's session.
const minted = await as("acme-co")(
  endpoint,
  post({
    op: "presign",
    files: [{ name: "a.txt", size: 1, type: "text/plain" }],
  })
);
const [upload] = (await minted.json()).uploads;
const completed = await json({
  op: "complete",
  completions: [{ id: upload.id, key: upload.key }],
});
const outcome = await completed.json();
assert.equal(outcome.files.length, 0);
assert.equal(outcome.errors[0].error.code, "Unauthorized");

// Whatever the responses said, acme-co's prefix must be exactly as it was.
const after = await files.list({ prefix: "orgs/acme-co/" });
assert.deepEqual(
  after.items.map((file) => file.key),
  [fullKey]
);
const stored = await files.download(fullKey);
assert.equal(await stored.text(), "acme-co only");

console.log(`${cases.length + 1} cross-tenant requests behaved as expected`);

Run it with bun scripts/check-tenant-isolation.ts, or move the cases into your test runner. It needs no credentials or network, so it can run on every change to the policy. Requests without an Origin header pass the gateway’s origin check, which is why the script doesn’t set one.

A prefix is only a naming convention

Storage providers don’t know what a tenant is. A prefix is the first part of a key, and the gateway only enforces it for requests that go through the gateway. Any server code holding the unscoped files instance can read every tenant, and a string prefix without a trailing slash matches more than you meant:

await files.list({ prefix: "orgs/acme" });
// Returned orgs/acme/… and also orgs/acme-co/report.pdf and orgs/acme-co/secret.txt

For server code that works on behalf of one tenant, such as background jobs, server actions, or webhook handlers, build a scoped instance instead of passing raw keys around:

import { createFiles } from "files-sdk";

import { files } from "@/lib/files";

export function tenantFiles(orgId: string) {
  return createFiles({ adapter: files.adapter, prefix: `orgs/${orgId}` });
}

A constructor prefix matches on a path boundary, so orgs/acme never lists orgs/acme-co/. It returns keys relative to the prefix, and it refuses keys with . or .. segments (key must not contain . or .. path segments). Add .readonly() for code that should never write.

The gateway is an application boundary

authorize controls what the gateway will do for a browser. It can’t control what happens without the gateway:

  • Bucket credentials reach every tenant. The R2 token or AWS keys in your server environment can read and write all of orgs/. A leaked key, or any server route that calls files directly, skips authorize completely.
  • Signed URLs are bearer tokens. The URL behind a download redirect or a url call works for anyone who has it until it expires. The gateway can’t revoke it.
  • Upload tokens are bearer tokens too. The token presign returns is bound to the minted key and the endpoint, not to the session. The proxy PUT that redeems it doesn’t run authorize, so anyone holding the token can write that one key until it expires (or, with a completions store, until complete accepts the upload, after which the proxy PUT answers 409). Replaying one tenant’s token from another tenant’s session wrote into the first tenant’s prefix. The token is signed, not encrypted, so whoever holds it can also read the storage key inside it.

Short lifetimes limit the last two, and provider-side permissions limit the first.

The complete step after an upload is checked against the session: it runs authorize and refuses a token whose key isn’t under the caller’s keyPrefix. In the replay above, the second tenant’s complete returned no file, only an Unauthorized entry reading upload token was not issued for this caller, so it never saw the stored object’s size, type, or metadata. The bytes it sent stayed in the first tenant’s prefix, under the minted key.

Default lifetime vs enforced ceiling

These settings sound alike but do different jobs:

Setting Where it goes What it does
defaultExpiresIn (default 300 seconds) createFilesRouter The lifetime the gateway uses when the browser doesn’t ask for one. A browser can ask for more.
maxExpiresIn returned from authorize A per-request ceiling on gateway url and download URLs, signed upload URLs, and upload tokens.
signedUrlPolicy({ maxExpiresIn }) first in plugins A ceiling on every url() and signedUploadUrl() on that Files instance, including server code that never touches the gateway.
defaultUrlExpiresIn (3600 seconds on S3 and R2) adapter options The lifetime server code gets from files.url(key) without expiresIn.

With no ceiling, a url request asking for expiresIn: 604800 got exactly that: seven days, the most SigV4 signing allows on S3 and R2. With maxExpiresIn: 300 from authorize, the same request got 300 seconds (X-Amz-Expires=300 against MinIO). A request for less than the ceiling keeps its shorter lifetime.

To cap server-side calls as well, add the signedUrlPolicy() plugin. Put it first in plugins, so it rewrites the options before any other plugin or the adapter sees them:

import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { signedUrlPolicy } from "files-sdk/signed-url-policy";

export const files = createFiles({
  adapter: r2({ bucket: "uploads", client: "fetch" }),
  plugins: [signedUrlPolicy({ maxExpiresIn: 300 })],
});

A call without expiresIn is pinned to the cap, and a longer request is clamped to it. The plugin also forces Content-Disposition: attachment on url() by default. On adapters that can’t set a response disposition, such as Vercel Blob or an R2 binding without HTTP credentials, that makes url() throw; pass disposition: false there to keep only the expiry cap.

Throw a FilesError from authorize

The gateway maps a thrown FilesError to its status code. Anything else becomes a 500:

authorize throws Response
new Error("Not signed in") 500, {"error":{"code":"Provider","message":"Not signed in"}}
new FilesError("Unauthorized", …) 401
new FilesError("ReadOnly", …) 403
new FilesError("NotFound", …) 404

A 500 tells the browser that storage failed, not that the user is signed out, so your UI shows the wrong error. A plain error’s message also reaches the browser as-is. If your session lookup can throw (a database timeout, say), catch it in authorize and throw a FilesError with a message you’re happy to show.

Delete a tenant’s data

When a tenant leaves, list everything under its prefix and delete it in bulk:

import { createFiles } from "files-sdk";

import { files } from "@/lib/files";

export async function deleteTenantFiles(orgId: string) {
  // Same adapter, scoped to one tenant: keys come back relative to orgs/<id>/.
  const tenant = createFiles({
    adapter: files.adapter,
    prefix: `orgs/${orgId}`,
  });

  const keys: string[] = [];
  for await (const file of tenant.listAll()) {
    keys.push(file.key);
  }
  const { results: deleted, errors } = await tenant.delete(keys);
  return { deleted: deleted.length, failed: errors ?? [] };
}

The bulk delete doesn’t throw when individual keys fail; it reports them in errors, so check that and run it again. Two places can still hold the tenant’s data afterwards:

  • Bucket versioning. On a versioned S3 bucket, DeleteObject without a version ID inserts a delete marker and keeps the old versions. Remove those versions or expire them with a lifecycle rule.
  • The versioning() and softDelete() plugins. They keep their copies outside the tenant prefix by default, under .versions/orgs/<id>/… and .trash/orgs/<id>/…. Delete those prefixes too, from server code: the gateway reserves both and answers 403 to any client key or listing inside them.

When a shared bucket isn’t enough

A shared bucket with gateway prefixes suits many tenants with the same settings, where your application is the only thing that touches storage. Move isolation down into the provider when a bug in your server code mustn’t be able to cross tenants, or when tenants need different settings.

Approach What enforces isolation Use it when
Shared bucket, keyPrefix from authorize (this guide) Your gateway Tenants share settings and only your app reaches storage.
Shared bucket, credentials scoped to a prefix The provider You want server code to be unable to cross tenants. On AWS, a policy on s3:prefix and the object ARN or an STS session policy limits a credential to one prefix. R2’s temporary credentials can be limited to prefixes.
Bucket per tenant, selected by a files factory Your factory, plus the provider if each bucket has its own credentials Tenants need their own lifecycle rules, versioning, CORS, encryption, region, or cost reporting.

To use prefix-scoped credentials from Files SDK, build each request’s instance in a files factory (below). s3() accepts credentials with a sessionToken, so it can take temporary credentials; r2() takes only an access key ID and secret.

Bucket-level settings apply to every tenant in a shared bucket; AWS’s multi-tenant patterns compare the options in more depth. Buckets also have quotas: S3 allows 10,000 general purpose buckets per account by default, raisable through Service Quotas, and R2 allows 1,000,000 per account.

For a bucket per tenant, give createFilesRouter a factory instead of an instance. It runs on every request, before authorize, and receives the same Request:

import { createFiles, FilesError } from "files-sdk";
import { createFilesRouter } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";
import { r2 } from "files-sdk/r2";
import { signedUrlPolicy } from "files-sdk/signed-url-policy";

import { getSession } from "@/lib/auth";

// One Files instance per tenant bucket, built on first use.
const byOrg = new Map<string, ReturnType<typeof filesForOrg>>();

function filesForOrg(orgId: string) {
  return createFiles({
    adapter: r2({ bucket: `tenant-${orgId}`, client: "fetch" }),
    plugins: [signedUrlPolicy({ maxExpiresIn: 300 })],
  });
}

const router = createFilesRouter({
  files: async (req) => {
    const session = await getSession(req.headers);
    if (!session) {
      throw new FilesError("Unauthorized", "Sign in to manage files");
    }
    const { orgId } = session.user;
    let files = byOrg.get(orgId);
    if (!files) {
      files = filesForOrg(orgId);
      byOrg.set(orgId, files);
    }
    return files;
  },
  operations: ["list", "head", "download", "url", "upload", "delete"],
});

export const { GET, POST, PUT } = createRouteHandler(router);

The gateway is still deny-by-default with a factory, so it needs operations or authorize. This version has no authorize, which means no maxExpiresIn; the signedUrlPolicy() plugin on each instance provides the ceiling instead. A factory that throws a FilesError gets the same status mapping as authorize. Create each bucket when you provision the tenant; the adapter doesn’t create buckets.

A factory also works for a shared bucket: return tenantFiles(orgId) from the section above, and the Files instance enforces the prefix instead of keyPrefix. Against the same probes it gave the same results: 422 for traversal keys and 404 for full storage keys.

A bucket per tenant only becomes a provider-enforced boundary when each instance uses credentials limited to its own bucket. R2 API tokens with object permissions can be scoped to specific buckets. With one token for every bucket, isolation still rests on the factory picking the right bucket, the same trust you place in keyPrefix. The multiple buckets page covers choosing an instance from the request when the choice isn’t tied to the session.

Last updated on

Was this page helpful?