Authenticate uploads with Clerk
Clerk's backend client authenticates a plain Request, which is exactly what
upup's getUserId hook receives. The same recipe therefore works unchanged in
Next.js, Express, Fastify, Hono, or a worker: Clerk verifies the session, upup
scopes the object key and the upload token to the returned userId.
The handler
import { createUpupHandler } from '@upupjs/server'
import { createClerkClient } from '@clerk/backend'
const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! })
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 state = await clerk.authenticateRequest(req, {
authorizedParties: [process.env.APP_ORIGIN!],
})
if (!state.isAuthenticated) return null
return state.toAuth().userId ?? null
},
})An unauthenticated request — no session cookie, no bearer token, an expired
token — takes the return null branch, and POST /presign and
POST /multipart/init answer 401 Unauthenticated. Nothing is presigned, so
no upload capability is ever handed to an anonymous caller.
Always pass authorizedParties
Without authorizedParties, a token minted for a different origin can
authenticate here. It is the check that ties a verified Clerk session to
your application rather than to any application in the Clerk ecosystem.
Set it to your app's origin in every environment, not only production.
Inside a Next.js app
If you are already using @clerk/nextjs, use the framework client rather than
constructing a backend client yourself — authenticateRequest is identical on
both:
import { clerkClient } from '@clerk/nextjs/server'
getUserId: async req => {
const clerk = await clerkClient()
const state = await clerk.authenticateRequest(req, {
authorizedParties: [process.env.APP_ORIGIN!],
})
if (!state.isAuthenticated) return null
return state.toAuth().userId ?? null
},Gating on organization or plan
toAuth() carries more than the user id — organization membership, roles, and
session claims. Gate on them in the same hook and return null when the caller
does not qualify, so there is exactly one place that decides whether an upload
is allowed:
getUserId: async req => {
const state = await clerk.authenticateRequest(req, {
authorizedParties: [process.env.APP_ORIGIN!],
})
if (!state.isAuthenticated) return null
const { userId, orgId } = state.toAuth()
if (!orgId) return null // personal-account uploads not allowed
return userId ?? null
},Return the user id, not the org id, unless every member of an organization should genuinely share one upload namespace — the returned value is both the storage namespace and the token binding.
Multipart uploads and the bound uid
Clerk's userId is stable for the life of the account, which is what the
multipart path requires. The id is baked into the HMAC upload token issued at
POST /multipart/init as uid, and sign-part, complete, and abort each
re-resolve the caller through getUserId and compare against it. A mismatch is
403 AUTH_DENIED, so a leaked upload token cannot be replayed by another
signed-in Clerk user.
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 upup enforces once Clerk has told it who the caller is.