payloadcms/payload · error · APIError

Invalid or expired staged upload.

Error message

Invalid or expired staged upload.

What it means

Thrown by `verifyUploadID` when the signed upload JWT cannot be verified or its payload is structurally invalid. This covers expiration (1-hour window), signature mismatch, non-string input, or missing/malformed fields (id, collectionSlug, filename, filesize, mimeType, user).

Source

Thrown at packages/payload/src/uploads/stagedUpload.ts:269

    const secret = new TextEncoder().encode(req.payload.secret)
    const { payload } = await jwtVerify<StagedUploadToken>(uploadID, secret)

    if (
      typeof payload.id !== 'string' ||
      !/^[\da-f-]{36}$/i.test(payload.id) ||
      typeof payload.collectionSlug !== 'string' ||
      typeof payload.filename !== 'string' ||
      !Number.isSafeInteger(payload.filesize) ||
      payload.filesize < 0 ||
      typeof payload.mimeType !== 'string' ||
      (payload.user !== null && typeof payload.user !== 'string')
    ) {
      throw new Error()
    }

    return payload as StagedUploadToken
  } catch {
    throw new APIError('Invalid or expired staged upload.', 400)
  }
}

View on GitHub (pinned to 00c58b35c0)

Solutions

  1. Regenerate the upload instructions -- if the secret rotated or the token expired, the old uploadId is unrecoverable.
  2. Ensure `PAYLOAD_SECRET` (or `secret`) is identical across all instances/replicas processing uploads.
  3. Complete the full upload flow (instructions -> PUT -> consume) within the one-hour token window.
  4. Pass the uploadId exactly as received -- do not re-encode, trim, or URL-decode it.

Example fix

// before -- secret mismatch between staging server and consuming server
// server A: PAYLOAD_SECRET=alpha  -> generates uploadId
// server B: PAYLOAD_SECRET=beta   -> verifies uploadId -> error

// after
// docker-compose / env: set the same secret everywhere
PAYLOAD_SECRET=shared-secret-value
Defensive patterns

Strategy: validation

Validate before calling

// Ensure PAYLOAD_SECRET is consistent and the flow completes within 1 hour
const elapsed = Date.now() - instructionsGeneratedAt
if (elapsed > 60 * 60 * 1000) {
  throw new Error('Upload instructions expired -- regenerate')
}
if (!process.env.PAYLOAD_SECRET) throw new Error('PAYLOAD_SECRET not set')

Try / catch

try {
  await verifyUploadId(uploadId)
} catch (e) {
  if (e instanceof APIError && e.message === 'Invalid or expired staged upload.') {
    // regenerate instructions with current secret
  } else throw e
}

Prevention

When it happens

Trigger: The `uploadId` is missing, not a string, tampered with, signed with a different `payload.secret`, or expired (>1 hour old). Also thrown if any required claim inside the JWT fails the runtime type/narrowing checks.

Common situations: Server `PAYLOAD_SECRET` changed between staging and consuming (e.g. rotated key, different env); the uploadId sat in a client queue longer than one hour; the JWT was URL-decoded incorrectly; a load balancer routes staging and consuming to servers with different secrets.

Related errors


AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12). Data as JSON: /api/errors/60cd3cf00d301746. Report an issue: GitHub.