# Server Auth & Trust Model

`@useupup/server` is the trust boundary between the browser and your storage.
In server mode the browser never holds storage credentials — it asks your
handler to presign a PUT or to run a multipart upload, and the handler decides
who is allowed and where the bytes land. This page explains that decision:
the mandatory secret, the secure-by-default gate, per-user scoping, and the
signed upload token that makes the multipart lifecycle tamper-resistant.

Everything here is enforced by `createUpupHandler` — the single factory
exported from `@useupup/server`.

<TrustModelDiagram />

## The mandatory upload-token secret

`createUpupHandler` **throws at construction** unless you pass a
`uploadTokenSecret` of at least 16 characters. There is no way to run the
handler without it, because the multipart routes are always live and every
multipart session issues and verifies a signed token (see
[The upload token](#the-upload-token)).

```ts
import { createUpupHandler } from '@useupup/server'

const handler = createUpupHandler({
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
    },
    // Required. >= 16 chars, stable, high-entropy, and identical across
    // every server instance / worker. Generate with `openssl rand -hex 32`.
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
})
```

Give it a stable, high-entropy value from your environment
(`UPUP_UPLOAD_TOKEN_SECRET` is the conventional name) and share the **same**
value across every instance and worker — a multipart session initialized on
one node must verify on another. A missing or too-short secret raises a
`UpupConfigError` at boot, not a confusing 500 at request time.

## Secure by default: anonymous uploads are off

The two capability-granting routes — `POST /presign` and
`POST /multipart/init` — refuse an unidentified caller. If you configure
none of `auth`, `getUserId`, or `allowAnonymousUploads`, those routes return
**403** with the code `AUTH_REQUIRED`:

```json
{
    "error": "Anonymous uploads are disabled. Set allowAnonymousUploads:true, or configure auth/getUserId.",
    "code": "AUTH_REQUIRED"
}
```

You unlock uploads by configuring exactly one of three things, depending on
how much you want the handler to know about the user:

```ts
// 1. Gate every request behind your own check (session cookie, bearer token…).
//    `auth` returns true to allow, false to reject with 401.
createUpupHandler({
    storage,
    uploadTokenSecret,
    auth: async req => Boolean(await getSessionFromCookie(req)),
})

// 2. Resolve a stable per-user id. Uploads are namespaced by it, and the
//    multipart token is bound to it (cross-user replay → 403). Preferred
//    for multi-tenant apps.
createUpupHandler({
    storage,
    uploadTokenSecret,
    getUserId: async req => (await getSessionFromCookie(req))?.userId ?? null,
})

// 3. Explicitly allow unauthenticated uploads under ONE shared namespace.
//    Demos, or deployments where auth already happened upstream. Never in
//    multi-tenant production — it prints a loud warning at boot.
createUpupHandler({
    storage,
    uploadTokenSecret,
    allowAnonymousUploads: true,
    maxFileSize: 25 * 1024 * 1024, // 25 MB, bytes
    allowedTypes: ['image/*', 'application/pdf'],
})
```

The signatures are exactly:

| Option                  | Type                                        | Effect                                                                     |
| ----------------------- | ------------------------------------------- | -------------------------------------------------------------------------- |
| `auth`                  | `(req: Request) => Promise<boolean>`        | Global gate. `false` → `401 Unauthorized` before any route runs.           |
| `getUserId`             | `(req: Request) => Promise<string \| null>` | Resolve the user. `null` → `401 Unauthenticated` inside the route.         |
| `allowAnonymousUploads` | `boolean`                                   | Opt in to a shared anonymous namespace for `/presign` + `/multipart/init`. |

`auth` and `getUserId` compose: use `auth` as a coarse allow/deny gate and
`getUserId` to scope storage and bind the upload token. If you set neither and
leave `allowAnonymousUploads` off, the upload routes stay closed.

> `getUserId` also scopes cloud-drive OAuth tokens in the `tokenStore`. If you
> configure `providers` or a `tokenStore` without `getUserId`, the handler
> throws at construction unless you set `allowAnonymous: true` (the
> drive-scoped sibling of `allowAnonymousUploads`).

## The upload token

Multipart uploads run over five requests — `init`, `sign-part`, `complete`,
`abort`, `resume`. The client cannot be trusted to re-assert which object key or
S3 `uploadId` it is continuing, so the server issues a **stateless, HMAC-signed
token** at `init` and re-derives everything from the verified token on every
later step.

The token binds:

- **`k`** — the object key the upload targets (the server chose it, not the client).
- **`u`** — the S3 multipart `uploadId`.
- **`uid`** — the resolved user id (or `null` for an anonymous upload).
- **`smin` / `smax`** — the allowed total-size envelope, in bytes.
- **`exp`** — expiry, epoch seconds. Default TTL is one hour.
- **`iat`** — the issue time of the **original** `init`, epoch seconds. Absent
  on tokens minted before cross-reload resume shipped; `resume` then derives it
  from `exp`, since `init` is the only issuer and has always used the same TTL.

It is signed with HMAC-SHA-256 over the payload using your
`uploadTokenSecret`, via Web Crypto (so it works on Node 20+, edge runtimes,
and Cloudflare Workers). On every continuation request the handler verifies the
signature **before** trusting any payload byte, compares it in constant time,
and checks expiry — the one exception being `resume`, which applies the resume
window described below instead. A tampered or forged token is rejected with
**403** and a code that names the failure:

```json
{ "error": "Invalid upload token", "code": "bad_signature" }
```

The `code` is one of `malformed`, `bad_signature`, or `expired`.

### The resume window

`POST /multipart/resume` is the one route that accepts a token whose `exp` has
passed — handing an expired token back as a fresh one is precisely its job, and
an upload can easily outlive a one-hour TTL. Signature and shape verification
are unchanged; only the expiry check is relaxed, and a tighter bound replaces
it:

- The **resume window** is `multipartResumeWindowSeconds`, 24 hours by default,
  measured from the token's `iat` — the original `init`, not the most recent
  re-issue. Past that, `resume` answers `403` with code `expired`.
- Re-issued tokens carry the original `iat` forward unchanged, so a chain of
  resumes cannot extend the window. It is a fixed-length extension of a token's
  life, never an open-ended renewal.
- Set `multipartResumeWindowSeconds: 0` to remove the route entirely.

**The trade, stated plainly:** a leaked token is usable for the window instead
of an hour. What it can do in that time is unchanged and narrow — continue the
same upload, to the same key, inside the same signed size envelope, still
owner-bound when `getUserId` is configured. It cannot start an upload, retarget
one, or widen the envelope.

The other four routes keep rejecting expired tokens exactly as before.

### Owner binding

When `getUserId` is configured, `complete` / `sign-part` / `abort` / `resume`
also re-check the caller's current identity against the token's bound `uid`. A
token that leaked to a different authenticated user cannot be replayed —
the mismatch returns **403 `AUTH_DENIED`**:

```json
{
    "error": "Upload token does not belong to the current user",
    "code": "AUTH_DENIED"
}
```

Without `getUserId`, `uid` is always `null` (there was no identity to bind at
`init`), so this check is skipped and **possession of the token is the model** —
anyone holding a valid, unexpired token can continue the session. That is an
intentional trade-off for token-possession deployments; set `getUserId` if you
need per-user enforcement.

### Signed size envelope

The declared file size at `init` is signed into `smax`. Because `sign-part`
and the browser's direct PUTs never re-send the size, the handler enforces the
envelope at `complete`: it sums the bytes S3 actually received (via
`ListParts`) and, if the real total falls outside `[smin, smax]`, it **aborts
the upload** and returns **403**:

```json
{ "error": "Upload size outside signed envelope" }
```

This stops a client from declaring a tiny size at `init` and then streaming an
arbitrarily large object. Signing both ends (`smin` is `0` by default) means the
accepted range is fixed by the server at init — a client can't widen it from
either side.

## Server-chosen keys

The client never chooses the storage key. By default the handler namespaces
every object as `<userId|anon>/<uuid>/<sanitized-filename>`. Override with
`keyStrategy` if you need your own layout — it receives the resolved user id
(or `null`), file name, content type, and size:

```ts
createUpupHandler({
    storage,
    uploadTokenSecret,
    getUserId,
    keyStrategy: ({ userId, fileName }) =>
        `tenants/${userId ?? 'anon'}/${Date.now()}-${fileName}`,
})
```

## Metadata policy: size and type

`maxFileSize` (bytes) and `allowedTypes` (an array of MIME patterns, with
`image/*`-style wildcards) are enforced on both the presign and multipart
paths before any storage call:

- Over `maxFileSize` → **413** `File too large`.
- Type not in `allowedTypes` → **415** `File type not allowed`.
- Malformed metadata → **400** `BAD_REQUEST`.

An `onBeforeUpload` hook — configured under `hooks`, i.e.
`config.hooks.onBeforeUpload`, not top-level — can reject a specific upload
(**403** `Upload rejected`) with your own logic. It receives the same
`{ name, size, type }` metadata plus the raw `Request`, which is where per-user
rules (plan caps, quotas) belong.

These are the only size and type limits on the server. The `<UpupUploader>`
props (`maxFiles`, `maxFileSize`, `allowedFileTypes`) are client-side UX and
never reach this handler — if you are porting a v1 route that read its limits
out of the request body, see
[Re-check upload limits on the server](/docs/migration/v1-to-v3/#re-check-upload-limits-on-the-server).

## What forged and unsigned requests get

- A `/presign` or `/multipart/init` with no configured auth path → **403
  `AUTH_REQUIRED`**.
- A continuation request with a forged, tampered, or expired token → **403**
  (`bad_signature` / `malformed` / `expired`).
- A continuation request whose caller is not the bound `uid` (when
  `getUserId` is set) → **403 `AUTH_DENIED`**.
- A `complete` whose real byte total is outside the signed envelope → **403**,
  and the S3 multipart upload is aborted so nothing partial is left behind.
- A request rejected by your `auth` gate → **401 `Unauthorized`**.

Every response — success or failure — carries an `x-upup-request-id` header so
you can correlate a client error with a server log line. Failures are logged
through the [`onError` seam](/docs/guides/error-monitoring/) with the route, method,
status, and a redacted error; secrets, tokens, and request bodies are never
logged.

## What this does _not_ protect

The trust model secures the wire: it prevents tampered sizes and keys,
cross-user continuation replay, and forged tokens. It does **not** validate the
quality of your own `auth` / `getUserId` implementations — if your session
check accepts a spoofable cookie, the handler faithfully trusts whatever user
it returns. Treat `auth` and `getUserId` as security-critical code, and keep
`uploadTokenSecret` out of source control and rotated like any other secret.

## Recipes

**Session-cookie app, per-user storage.** The common case: authenticate with a
cookie and scope every object to the user.

```ts
import { createUpupHandler, InMemoryTokenStore } from '@useupup/server'

export const handler = createUpupHandler({
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
    },
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
    tokenStore: new InMemoryTokenStore(), // swap for Redis/KV in production
    getUserId: async req => {
        const session = await getSessionFromCookie(req)
        return session?.userId ?? null // null → 401, upload refused
    },
})
```

**Public drop-box with limits.** Accept uploads from anyone, but cap size and
type. The shared anonymous namespace is explicit and logged at boot.

```ts
export const handler = createUpupHandler({
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
    },
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
    allowAnonymousUploads: true,
    maxFileSize: 10 * 1024 * 1024, // 10 MB
    allowedTypes: ['image/*'],
})
```

> `InMemoryTokenStore` is a reference implementation — fine for demos and
> single-process dev, but it loses tokens on restart and is not shared across
> workers. Implement the `TokenStore` interface (`get` / `set` / `delete`)
> against Redis, Cloudflare KV, or your database for production.

See [Client Mode vs Server Mode](/docs/guides/modes/) for when to reach for server mode,
[Server Mode — Setup](/docs/guides/server-mode-setup/) for wiring the handler into
Next.js, Express, Fastify, or Hono, and [Storage Providers](/docs/guides/storage-providers/)
for the `storage` block of any S3-compatible backend.
