Authenticate uploads with Better Auth
Better Auth's server API takes a Headers object, which is exactly what upup's
getUserId hook already has. Nothing is proxied or re-parsed — this is the same
getSession call your own route handlers make, running inside the same request.
The handler
import { createUpupHandler } from '@upupjs/server'
import { auth } from '@/lib/auth' // your betterAuth(...) instance
export const handler = createUpupHandler({
storage: {
type: 'aws',
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
},
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
getUserId: async req => {
const session = await auth.api.getSession({ headers: req.headers })
return session?.user.id ?? null
},
})That is the whole Better Auth file-upload integration. getSession returns
null for an absent or expired cookie, so the ?? null branch is the
unauthenticated path: POST /presign and POST /multipart/init answer 401
Unauthenticated and nothing is presigned.
Mount it the way your framework expects — the recipe is identical in Next.js,
Express, Fastify, and Hono, because the hook only ever sees a standard web
Request. See Server Mode — Setup for the
per-framework mounting code.
Gating on more than existence
If a signed-in session is not enough — you require a verified email, an active
plan, an org membership — return null from the same hook rather than adding a
second check somewhere else. One hook, one answer:
getUserId: async req => {
const session = await auth.api.getSession({ headers: req.headers })
if (!session?.user.emailVerified) return null
return session.user.id
},The same shape works for a plan check (if (!session.user.proPlan) return null)
or an organization lookup. Keep the hook cheap: it runs on every presign and on
every multipart continuation request.
Return a stable id
session.user.id is Better Auth's immutable primary key, which is what you
want. Do not substitute the email or a per-session token — the id becomes
the storage namespace and is baked into the upload token, so a value that
changes between requests breaks an in-flight multipart upload. Never return
the literal string default either; that is upup's internal
anonymous-namespace sentinel.
What upup does with the id
The returned id is enforced end to end. Object keys are namespaced by it, and
the HMAC upload token issued at POST /multipart/init bakes it in as uid.
Every multipart continuation route — sign-part, complete, abort — re-resolves
the caller through this same getUserId hook and compares it to that bound
uid. A mismatch is 403 AUTH_DENIED, so a leaked upload token cannot be
replayed by a different signed-in user.
This is why a stable id matters more than a convenient one: a multipart upload
started under one value and continued under another fails owner binding at
complete, even though both requests carry a perfectly valid Better Auth
session.
Next steps
- Auth Recipes — the hub: the hook model, the
authgate, the anonymous opt-outs, andTokenStoreimplementations for cloud-drive tokens. - Server Mode — Setup — mounting the handler on Next.js, Express, Fastify, or Hono.
- Server Auth & Trust Model — what the enforcement actually guarantees, and what it deliberately does not protect.