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.
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).
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:
{
"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:
// 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.
getUserIdalso scopes cloud-drive OAuth tokens in thetokenStore. If you configureprovidersor atokenStorewithoutgetUserId, the handler throws at construction unless you setallowAnonymous: true(the drive-scoped sibling ofallowAnonymousUploads).
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 multipartuploadId.uid— the resolved user id (ornullfor 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 originalinit, epoch seconds. Absent on tokens minted before cross-reload resume shipped;resumethen derives it fromexp, sinceinitis 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:
{ "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'siat— the originalinit, not the most recent re-issue. Past that,resumeanswers403with codeexpired. - Re-issued tokens carry the original
iatforward 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: 0to 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:
{
"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:
{ "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:
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→ 413File too large. - Type not in
allowedTypes→ 415File 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
/presignor/multipart/initwith no configured auth path → 403AUTH_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(whengetUserIdis set) → 403AUTH_DENIED. - A
completewhose 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
authgate → 401Unauthorized.
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.
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.
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/*'],
})
InMemoryTokenStoreis a reference implementation — fine for demos and single-process dev, but it loses tokens on restart and is not shared across workers. Implement theTokenStoreinterface (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.