# Security (https://s3.dimah.dev/docs/server/security)



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.

<Callout type="warn">
  Never expose S3 credentials in the browser or client bundles. S3 clients must
  always run server-side.
</Callout>

## Security model [#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 [#scoping-objects--user-tenancy]

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

```ts title="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 [#object-acl]

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

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

***

## Hook error mapping [#hook-error-mapping]

| Hook Location                                      | Plain `Error` Thrown   | `errors.*` / `APIError` |
| -------------------------------------------------- | ---------------------- | ----------------------- |
| `guard` / `*Guard` / `object`                      | **403 Forbidden**      | Custom status & code    |
| `on*` lifecycle hooks (`onConfirmed`, `onDeleted`) | **500 Internal Error** | Custom status & code    |

## Frequently asked questions [#frequently-asked-questions]

<Accordions>
  <Accordion title="How do I verify the true MIME type (magic bytes) of an uploaded file?">
    The server checks MIME headers at presign and HeadObject. If you need strict magic-byte validation, use `sniffContentType` / `matchesMagicBytes` in `onConfirmed`. Throwing an error from `onConfirmed` automatically deletes the uploaded object from S3:

    ```ts
    import { matchesMagicBytes, sniffContentType } from "@dimah-s3/core";

    avatar: route({
      upload: {
        onConfirmed: async ({ key, client }) => {
          // Fetch the first few bytes
          const res = await client.send(
            new GetObjectCommand({
              Bucket: bucket,
              Key: key,
              Range: "bytes=0-15",
            }),
          );
          const bytes = new Uint8Array(await res.Body.transformToByteArray());

          if (
            !matchesMagicBytes(bytes, ["image/png", "image/jpeg", "image/webp"])
          ) {
            throw errors.fileTypeNotAllowed("Invalid image magic bytes");
          }
        },
      },
    });
    ```
  </Accordion>

  <Accordion title="How do I prevent users from deleting or downloading other users' files?">
    Use `guard` on download/delete or use the [`@dimah-s3/db`](https://s3.dimah.dev/docs/db) plugin for automatic scope isolation:

    ```ts
    avatar: route({
      delete: {
        guard: async ({ key, request }) => {
          const session = await getSession(request);
          const isOwner = await checkAvatarOwnership(session.userId, key);
          if (!isOwner) throw errors.forbidden();
        },
      },
    }),
    ```
  </Accordion>
</Accordions>
