dimah-s3v1.3.0

Security

Server-owned keys, ACL, and verified file metadata.

dimah-s3 follows a presign-first security model: AWS credentials remain on the server, while clients upload directly to storage via short-lived signed URLs.

Never expose S3 credentials in the browser or client bundles. S3 clients must always run server-side.

Security model

  1. Server owns keys: The client requests an upload for a named route. The server decides the destination key path (avatar/{uuid}/{fileName} or customized via object).
  2. Namespace isolation: Confirm, download, and delete follow-ups are restricted to the route's keyPrefix. Requests referencing keys outside the prefix fail with INVALID_KEY.
  3. Verified metadata via HeadObject: Presign payloads (size/type) are untrusted. Verified file sizes and Content-Types are confirmed server-side via HeadObject in upload.onConfirmed.
  4. Guards & Authorization: Route guard and feature guards (upload.guard, download.guard, delete.guard) control per-user authorization.

Scoping objects & user tenancy

Use upload.object to isolate files by tenant or user ID:

lib/s3.ts
import { dimahS3, errors, route } from "@dimah-s3/server";

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  routes: {
    avatar: route({
      guard: async ({ request }) => {
        const session = await getSession(request);
        if (!session) throw errors.unauthorized();
      },
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        object: async ({ request }) => {
          const session = await getSession(request);
          return {
            folder: `users/${session.userId}`,
            // Generates: avatar/users/{userId}/{uuid}/{fileName}
          };
        },
        onConfirmed: async ({ key, contentLength, request }) => {
          // Trusted size & key confirmed from S3 HeadObject
          const session = await getSession(request);
          await db.user.update({
            where: { id: session.userId },
            data: { avatarKey: key, avatarSize: contentLength },
          });
        },
      },
      download: true,
      delete: true,
    }),
  },
});

Object ACL

Uploads are private by default. Set acl on the route or return it dynamically from upload.object:

avatar: route({
  upload: {
    acl: "public-read",
    fileTypes: ["image/*"],
    maxFileSize: 2 * 1024 * 1024,
  },
}),

Hook error mapping

Hook LocationPlain Error Thrownerrors.* / APIError
guard / *Guard / object403 ForbiddenCustom status & code
on* lifecycle hooks (onConfirmed, onDeleted)500 Internal ErrorCustom status & code

Frequently asked questions

On this page