Documentation menu

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.

Browsermetadata + intent@useupup/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 '@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:

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

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 '@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 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.