For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: text/markdown.

Managing files

Upload and delete files with ownership-scoped API routes. Presigned URLs, content-type enforcement, and the avatar/logo pattern used in Core Kit.

Before you start managing files, make sure you have configured storage.

Permissions

Most S3-compatible storage providers allow you to configure bucket permissions and access policies. Properly set these up so files stay private and access stays intentional.

Key recommendations:

  • Keep your bucket private by default
  • Use IAM roles and policies to manage access
  • Enable server-side encryption for sensitive data
  • Configure CORS settings appropriately for client-side uploads
  • Regularly audit bucket permissions and access logs

Making your bucket public is strongly discouraged. It can expose sensitive data, allow unauthorized access, and drive unexpected bandwidth costs.

For detailed guidance on configuring bucket policies and permissions, refer to your storage provider's documentation:

Scope of the upload

Each product feature owns its upload and delete routes. The server:

  1. Authenticates (and authorizes) the caller
  2. Derives the object key from the session / route context (for example userId or organizationId)
  3. Validates contentType and contentLength
  4. Returns short-lived upload / delete URLs from @workspace/storage/server

Helpers such as getUploadUrl, getPublicUrl, getSignedUrl, and getDeleteUrl live in @workspace/storage/server. Feature routers call them; the browser never imports storage credentials.

Shipped examples:

ResourceRoutesOwnership source
User avatarPOST / DELETE /api/user/avatarSession user.id
Organization logoPOST / DELETE /api/organizations/:id/logoOrg id in the path + update permission

Shared path helpers and size limits live in packages/api/src/lib/avatar.ts (createAvatarPath, isOwnedAvatarPath, getAvatarPath, MAX_AVATAR_SIZE).

Uploading files

As explained in the overview, clients upload with presigned URLs. Request a URL from your feature route, then PUT the file directly to the storage provider.

Avatar upload on the API looks like this:

packages/api/src/modules/user/router.ts
export const userRouter = new Hono()
  .use(enforceAuth)
  .post("/avatar", validate("json", uploadAvatarSchema), async (c) => {
    const { contentType, contentLength } = c.req.valid("json");
    const path = createAvatarPath({ ownerId: c.var.user.id, contentType });

    const [{ url: uploadUrl }, { url: publicUrl }] = await Promise.all([
      getUploadUrl({ path, contentType, contentLength }),
      getPublicUrl({ path }),
    ]);

    return c.json({ uploadUrl, publicUrl, path });
  });

getUploadUrl signs ContentType and ContentLength into the S3 PutObject request. A holder of the URL cannot swap in a different MIME type or a larger payload.

Expiration time

The signed URL is only valid for a short window (uploads expire in 60 seconds) and works for anyone who has it during that period. Treat it like a temporary password.

On the client, request the URL with metadata only (no file path from the browser), then upload:

apps/web/src/modules/common/avatar-form.tsx
const { uploadUrl, publicUrl } = await handle(api.user.avatar.$post)({
  json: { contentType: avatar.type, contentLength: avatar.size },
});

const response = await fetch(uploadUrl, {
  method: "PUT",
  body: avatar,
  headers: {
    "Content-Type": avatar.type,
  },
});

if (!response.ok) {
  throw new Error("Failed to upload file");
}

await update(publicUrl);

Organization logos use the same flow against api.organizations[":id"].logo.$post, with enforceOrganizationPermission requiring organization: ["update"].

Adding your own upload feature

Follow the same pattern as avatars and logos:

  1. Add a route under the resource that owns the file (project, document, …)
  2. Build the object key on the server (files/${ownerId}/${crypto.randomUUID()}.…)
  3. Validate content type and size with Zod (uploadAvatarSchema is a good template)
  4. Call getUploadUrl({ path, contentType, contentLength }) and optionally getPublicUrl
  5. On delete, reject paths that fail an ownership check (isOwnedAvatarPath style)

Displaying files

For public objects (avatars and logos after a successful upload), persist and render the publicUrl returned with the upload. Bucket policy must allow public reads for those prefixes if you use this pattern.

For private objects, issue a time-limited GET URL from a protected route:

return c.json(await getSignedUrl({ path }));

Signed GET URLs expire after one hour by default. Do not store them as permanent references in the database; store the object key (or a stable public URL when that is intentional) and mint signed URLs when you need to display private content.

Deleting files

Delete routes also derive or verify ownership before returning a signed DELETE URL.

packages/api/src/modules/user/router.ts
.delete("/avatar", validate("query", getObjectUrlSchema), async (c) => {
  const { path } = c.req.valid("query");

  if (!isOwnedAvatarPath({ path, ownerId: c.var.user.id })) {
    throw new HttpException(HttpStatusCode.FORBIDDEN, {
      code: "error.forbidden",
    });
  }

  return c.json(await getDeleteUrl({ path }));
});

Client cleanup extracts the owned key from the stored image URL (getAvatarPath), requests the delete URL, then DELETEs against it. Social-provider avatars (URLs outside your avatars/ prefix) are skipped so you do not try to delete someone else's CDN object.

const path = getAvatarPath({ avatar: image, ownerId: id });
if (!path) return;

const { url } = await handle(api.user.avatar.$delete)({ query: { path } });
await fetch(url, { method: "DELETE" });

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter