2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

Security

Server-owned keys, route namespaces vs ownership, upload tickets, 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 (avatar/{uuid}/{name}, or what upload.object returns).
  2. Signed upload tickets — Presign returns an HMAC-signed ticket that names the route, key, and declared file. Confirm and the later multipart calls send only the ticket, so a client cannot point them at another key. A ticket can be bound to a user.
  3. Namespace isolation — keyPrefix defaults to the route name. Upload generates keys under it; download and delete reject keys outside it (KEY_INVALID). This check always runs in @dimah-s3/server.
  4. Verified metadata via HeadObject — Presign payloads (size and type) are untrusted. Confirm reads the stored size and type with HeadObject and checks them against the ticket and the route limits before onConfirmed runs.
  5. Guards and authorization — The instance guard, 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 download and delete 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?KEY_INVALID
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 KEY_INVALID.

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 keyFolder: 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

Resolve the user once in a guard, pass it on as locals, and use it to shape keys and bind tickets:

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

declare module "@dimah-s3/server" {
  interface DimahS3Locals {
    userId?: string;
  }
}

export const s3 = dimahS3({
  client: awsS3,
  bucket: process.env.S3_BUCKET!,
  guard: async ({ request }) => {
    const session = await getSession(request);
    if (!session) throw s3Error("UNAUTHORIZED");
    return { locals: { userId: session.userId } }; 
  },
  ticketSubject: ({ locals }) => locals.userId, 
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        object: ({ locals }) => ({
          keyFolder: `users/${locals.userId}`, 
          // Generates: avatar/users/{userId}/{uuid}/{name}
        }),
        onConfirmed: async ({ key, object, locals }) => {
          // Size from S3 HeadObject, not from the browser
          await db.user.update({
            where: { id: locals.userId },
            data: { avatarKey: key, avatarSize: object.size },
          });
        },
      },
      download: true,
      delete: true,
    }),
  },
});

ticketSubject binds each ticket to the user who started the upload. A leaked ticket alone cannot confirm or continue that upload for anyone else. onConfirmed runs at least once per upload, so keep it safe to replay, as this update is.

keyFolder: users/{userId} only shapes the key. The 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,
  },
}),

Request hardening

  • JSON only — Every body endpoint, plugin endpoints included, accepts application/json only. A cross-site HTML form cannot send that, so a simple form post returns 415 UNSUPPORTED_MEDIA_TYPE.
  • Body cap — maxRequestBodySize (1 MiB by default) is counted on the stream before parsing.
  • Signed headers — A presigned PUT signs every header the server returns in headers: type, disposition, metadata, ACL, tags, storage class, and checksum. A custom client must send exactly those headers.
  • Inline allow-list — Downloads open in the browser only for raster images, PDF, audio, video, and plain text, unless you set download.inlineTypes. HTML, SVG, and other types that run as a page are always downloaded as attachments.
  • Hardened proxy — GET /file sends a sandbox CSP, nosniff, and Cross-Origin-Resource-Policy on every response.

Hook error mapping

ThrownResult
s3Error(code) or another APIErrorIts own status and code
A plain Error, anywhere500 INTERNAL_ERROR, logged, passed to onError
Any throw from your upload.onConfirmed500 INTERNAL_ERROR; the object is kept

A guard that returns anything other than nothing or { locals } (for example true or false) is a config error: it is logged and returns 500. It never allows the call. See Errors.

Frequently asked questions

On this page