# Security (https://v1.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>

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

```ts
export const awsS3 = new S3Client({
  credentials: {
    accessKeyId: process.env.NEXT_PUBLIC_S3_ACCESS_KEY_ID!, // [!code --]
    secretAccessKey: process.env.NEXT_PUBLIC_S3_SECRET_ACCESS_KEY!, // [!code --]
  },
});
```

## 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** — `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.

<Callout type="warn" title="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.
</Callout>

---

## Route namespace vs ownership [#route-namespace-vs-ownership]

`keyPrefix` and a guard answer different questions:

| Check                        | Runs                          | Answers                                 | Typical failure       |
| ---------------------------- | ----------------------------- | --------------------------------------- | --------------------- |
| `keyPrefix`                  | Always, in `@dimah-s3/server` | Is this key inside this route's folder? | `INVALID_KEY`         |
| Guards or [`db()`](https://v1.s3.dimah.dev/docs/db) | Only if you add them          | May this caller touch this object?      | `401` / `403` / `404` |

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

```ts
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()`](https://v1.s3.dimah.dev/docs/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.

<Callout type="warn" title="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()`](https://v1.s3.dimah.dev/docs/db).

</Callout>

---

## Scoping objects and user tenancy [#scoping-objects-and-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}`, // [!code ++]
            // Generates: avatar/users/{userId}/{uuid}/{fileName}
          };
        },
        onConfirmed: async ({ key, contentLength, request }) => {
          // [!code ++]
          // Trusted stored size and server-owned key from S3 HeadObject
          const session = await getSession(request); // [!code ++]
          await db.user.update({
            // [!code ++]
            where: { id: session.userId }, // [!code ++]
            data: { avatarKey: key, avatarSize: contentLength }, // [!code ++]
          }); // [!code ++]
        }, // [!code ++]
      },
      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()`](https://v1.s3.dimah.dev/docs/db)) for
ownership.

---

## 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", // [!code ++]
    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?">

Read the object header with your configured, server-only S3 client and inspect it
in `onConfirmed`. The hook context contains the bucket and key, but not an S3
client:

```ts title="lib/s3.ts"
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { sniffContentType } from "@dimah-s3/core";
import { dimahS3, errors, route } from "@dimah-s3/server";
import { awsS3 } from "./aws-s3";

const bucket = process.env.S3_BUCKET!;
const allowedTypes = new Set(["image/png", "image/jpeg", "image/webp"]);

export const s3 = dimahS3({
  client: awsS3,
  bucket,
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/png", "image/jpeg", "image/webp"],
        onConfirmed: async ({ key, fileName }) => {
          const response = await awsS3.send(
            new GetObjectCommand({
              Bucket: bucket,
              Key: key,
              Range: "bytes=0-15",
            }),
          );
          if (!response.Body) throw errors.objectNotFound();

          const bytes = await response.Body.transformToByteArray();
          const detectedType = sniffContentType(bytes);
          if (!detectedType || !allowedTypes.has(detectedType)) {
            throw errors.fileTypeNotAllowed(fileName ?? key, detectedType);
          }
        },
      },
    }),
  },
});
```

The normal presign and `HeadObject` checks still run. If this stricter check
throws, dimah-s3 performs a compensating delete so the rejected object is not
left in S3.

</Accordion>

<Accordion title="How do I prevent users from deleting or downloading other users' files?">

`keyPrefix` only keeps `avatar` keys off the `document` route. It does not
know which user owns a file. Use `download.guard` / `delete.guard`, or the
[`@dimah-s3/db`](https://v1.s3.dimah.dev/docs/db) plugin for automatic scope isolation:

```ts
avatar: route({
  delete: true, // [!code --]
}),

avatar: route({
  delete: {
    guard: async ({ key, request }) => { // [!code ++]
      const session = await getSession(request); // [!code ++]
      const isOwner = await checkAvatarOwnership(session.userId, key); // [!code ++]
      if (!isOwner) throw errors.forbidden(); // [!code ++]
    }, // [!code ++]
  },
}),
```

</Accordion>

</Accordions>
