# Security (https://v2.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 (`avatar/{uuid}/{name}`, or what `upload.object` returns).
2. **Signed upload tickets** — Presign returns an HMAC-signed [ticket](https://v2.s3.dimah.dev/docs/server/upload-tickets) 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.

<Callout type="warn" title="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.
</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? | `KEY_INVALID`         |
| Guards or [`db()`](https://v2.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 `KEY_INVALID`.

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

</Callout>

---

## Scoping objects and user tenancy [#scoping-objects-and-user-tenancy]

Resolve the user once in a guard, pass it on as [`locals`](https://v2.s3.dimah.dev/docs/server/hooks/global-guard#locals),
and use it to shape keys and bind tickets:

```ts title="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 } }; // [!code ++]
  },
  ticketSubject: ({ locals }) => locals.userId, // [!code ++]
  routes: {
    avatar: route({
      upload: {
        fileTypes: ["image/*"],
        maxFileSize: 2 * 1024 * 1024,
        object: ({ locals }) => ({
          keyFolder: `users/${locals.userId}`, // [!code ++]
          // 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()`](https://v2.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,
  },
}),
```

---

## Request hardening [#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`](https://v2.s3.dimah.dev/docs/server/file-proxy) sends a
  sandbox CSP, `nosniff`, and `Cross-Origin-Resource-Policy` on every
  response.

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

| Thrown                                   | Result                                            |
| ---------------------------------------- | ------------------------------------------------- |
| `s3Error(code)` or another `APIError`    | Its own status and `code`                         |
| A plain `Error`, anywhere                | `500 INTERNAL_ERROR`, logged, passed to `onError` |
| Any throw from your `upload.onConfirmed` | `500 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](https://v2.s3.dimah.dev/docs/server/errors).

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

<Accordions>

<Accordion title="How do I verify the true MIME type (magic bytes) of an uploaded file?">

Set `verifyContent: true` on the route's `upload`. After confirm, the server
reads the first bytes of the object and checks them against the declared
type. A mismatch deletes the object and returns `FILE_CONTENT_MISMATCH`:

```ts title="lib/s3.ts"
avatar: route({
  upload: {
    fileTypes: ["image/png", "image/jpeg", "image/webp"],
    verifyContent: true, // [!code ++]
  },
}),
```

`verifyContent` also turns on `checksum`, so the bytes that were checked are
the bytes that stay. For a stricter rule, inspect the object yourself in
`confirmGuard`: a rejection there deletes the object too. `sniffContentType`
from `@dimah-s3/core` returns the detected type of a byte prefix.

</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://v2.s3.dimah.dev/docs/db) plugin for automatic scope isolation:

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

avatar: route({
  delete: {
    guard: async ({ key, locals }) => { // [!code ++]
      const isOwner = await checkAvatarOwnership(locals.userId, key); // [!code ++]
      if (!isOwner) throw s3Error("FORBIDDEN"); // [!code ++]
    }, // [!code ++]
  },
}),
```

</Accordion>

</Accordions>
