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.
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 '@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:
{
"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 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 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.
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:
{ "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:
{
"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.
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 '@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.
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.