Documentation menu

Server Auth & Trust Model

@upupjs/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 @upupjs/server.

Browsermetadata + intent@upupjs/serververify authsign lengthbind key / uploadIdHMAC-signed presignS3-compatiblestorageForged requestno valid token403

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).

ts
import { createUpupHandler } from '@upupjs/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:

OptionTypeEffect
auth(req: Request) => Promise<boolean>Global gate. false401 Unauthorized before any route runs.
getUserId(req: Request) => Promise<string | null>Resolve the user. null401 Unauthenticated inside the route.
allowAnonymousUploadsbooleanOpt 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 four requests — init, sign-part, complete, abort. 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.

It is signed with HMAC-SHA-256 over the payload using your uploadTokenSecret, via Web Crypto (so it works on Node 18+, 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. 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.

Owner binding

When getUserId is configured, complete / sign-part / abort 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 maxFileSize413 File too large.
  • Type not in allowedTypes415 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.

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 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 '@upupjs/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 for when to reach for server mode, Server Mode — Setup for wiring the handler into Next.js, Express, Fastify, or Hono, and Storage Providers for the storage block of any S3-compatible backend.