Skip to main content
General
Storage

Upload Files

Use the shipped image upload flow and understand the validation you must add for other files.

Open MarkdownFull AI corpusFeedback

Before uploading images, configure the storage provider.

Shipped upload flow

The repository includes purpose-specific tRPC mutations that return presigned PutObject URLs. The server derives the object key from the authenticated user or active organization:

trpc/routers/storage/index.ts
import {
  createTRPCRouter,
  protectedOrganizationProcedure,
  protectedProcedure
} from '@/trpc/init';
import { TRPCError } from '@trpc/server';

import { storageConfig } from '@/config/storage.config';
import { canUploadOrganizationLogo } from '@/lib/auth/organization-permissions';
import { getSignedUploadUrl } from '@/lib/storage';

export const storageRouter = createTRPCRouter({
  userAvatarUploadUrl: protectedProcedure.mutation(async ({ ctx }) => {
    const path = `${ctx.user.id}-${crypto.randomUUID()}.png`;
    const signedUrl = await getSignedUploadUrl(
      path,
      storageConfig.bucketNames.images
    );
    return { path, signedUrl };
  }),
  organizationLogoUploadUrl: protectedOrganizationProcedure.mutation(
    async ({ ctx }) => {
      if (!canUploadOrganizationLogo(ctx.membership.role)) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }

      const path = `logo-${ctx.organization.id}-${crypto.randomUUID()}.png`;
      const signedUrl = await getSignedUploadUrl(
        path,
        storageConfig.bucketNames.images
      );
      return { path, signedUrl };
    }
  )
});

The avatar procedure requires a signed-in user. The organization-logo procedure additionally requires owner or admin membership in the active organization. Neither procedure accepts a client-selected bucket or object path.

The avatar and organization logo components then:

  1. Accept PNG or JPEG input in the browser.
  2. Open CropImageModal and produce a cropped image blob.
  3. Request a signed upload URL and server-generated .png object key.
  4. Upload the blob directly with PUT.
  5. Save the object key to Better Auth after the upload succeeds.

Requesting an upload URL

components/example-image-upload.tsx
const { path, signedUrl } =
  await trpc.storage.userAvatarUploadUrl.mutateAsync();

const response = await fetch(signedUrl, {
  method: 'PUT',
  body: imageBlob,
  headers: {
    'Content-Type': 'image/png'
  }
});

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

This example mirrors the included avatar and logo components. It is not a generic file upload API.

Current validation

getSignedUploadUrl rejects absolute paths, hidden path segments, null bytes, .. and characters outside its allowlist. It signs the URL for 60 seconds.

The shipped server derives avatar and organization-logo keys from the authenticated context. It does not:

  • Enforce a maximum byte size
  • Inspect the uploaded file contents
  • Create file metadata or enforce storage plan limits

The browser file picker accepts image types, but client validation is not a security boundary.

Content type detail

The current PutObjectCommand sets ContentType to image/jpeg, while the included crop upload components send Content-Type: image/png. Providers can enforce signed headers differently. If uploads fail with a signature mismatch, make the signer and client use the same content type.

When adding multiple upload types, accept a small server-validated content type enum and pass the validated value into PutObjectCommand. Do not forward an arbitrary header from the client.

Production hardening

The following controls are customizations and do not ship in the repository:

  1. Validate an allowed content type and file size before signing.
  2. Enforce provider-side upload limits where your S3-compatible provider supports them.
  3. Add a file metadata record with ownership and an upload lifecycle state.
  4. Confirm the object after upload before marking the record ready.
  5. Add rate limits, quotas, malware scanning and orphan cleanup as required.

Do not add private document uploads to the existing image signer without also implementing the authorized read flow described in Access Files.