dimah-s3v1.5.11

Security

Server-owned keys, route namespaces vs ownership, 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.

Public NEXT_PUBLIC_ keys in a browser bundle are a leak, not a configuration shortcut:

export const awsS3 = new S3Client({
  credentials: {
    accessKeyId: process.env.NEXT_PUBLIC_S3_ACCESS_KEY_ID!, 
    secretAccessKey: process.env.NEXT_PUBLIC_S3_SECRET_ACCESS_KEY!, 
  },
});

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 — keyPrefix defaults to the route name. Upload generates keys under it; confirm, download, and delete reject keys outside it (INVALID_KEY). This check always runs in @dimah-s3/server.
  3. Verified metadata via HeadObject — Presign payloads (size/type) are untrusted. Stored byte length and Content-Type metadata are read server-side via HeadObject in upload.onConfirmed.
  4. Guards and authorization — Route guard and feature guards (upload.guard, download.guard, delete.guard) control per-user authorization. Namespace isolation is not ownership.

Disabling keyPrefix removes route isolation

keyPrefix: false allows follow-up operations to target any safe key in the bucket. Use it only when strict route or operation guards enforce the complete ownership policy.


Route namespace vs ownership

keyPrefix and a guard answer different questions:

CheckRunsAnswersTypical failure
keyPrefixAlways, in @dimah-s3/serverIs this key inside this route's folder?INVALID_KEY
Guards or db()Only if you add themMay this caller touch this object?401 / 403 / 404

The same prefix is used on the way in and the way out. With two routes:

avatar: route({ upload: { fileTypes: ["image/*"] }, delete: true }),
document: route({ upload: { fileTypes: ["application/pdf"] }, delete: true }),
  • Upload on avatar creates avatar/{uuid}/photo.jpg — the client does not send a key.
  • DELETE ?route=avatar&key=avatar/{uuid}/photo.jpg is in-namespace.
  • DELETE ?route=avatar&key=document/{uuid}/cv.pdf is INVALID_KEY.

db() does not replace that prefix check. It stores a route column for listings; its guards look up (bucket, key) and compare scope. A key from another route is still rejected by the server before those guards run.

Enabled delete is not per-user authorization

delete: true or download: true with no ownership guard means anyone who can call the endpoint and knows a key in that namespace can delete or download that object. A UUID in the path makes keys hard to guess; it is not an access check. Returning folder: users/{userId} from upload.object only changes the key shape.

A guard that only checks "is the user signed in?" still lets any signed-in user target any namespaced key they know. Enforce ownership in download.guard / delete.guard, or attach db().


Scoping objects and 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 stored size and server-owned key 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,
    }),
  },
});

Returning folder: users/{userId} only shapes the key. The route guard above requires a session; it does not stop one signed-in user from targeting another user's key. Add download.guard / delete.guard (or db()) for ownership.


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