Jun 3, 2026
Next.js File Upload Security for App Router Apps
A practical guide to Next.js file upload security in App Router apps covering upload validation, size limits, MIME checks, storage isolation, signed URLs, and safer route handlers.
8 min read
Next.js File Upload Security for App Router Apps
If you are searching for Next.js file upload security, you are probably adding avatars, documents, product images, CSV imports, support attachments, or AI knowledge-base files to an App Router application. The feature looks simple in the UI: pick a file, press upload, show a preview. The backend risk is larger. Upload endpoints accept attacker-controlled bytes, filenames, MIME types, sizes, metadata, and repeated requests.
This guide focuses on practical secure file uploads Next.js teams can ship without turning the upload flow into a fragile custom platform. You will validate files at the server boundary, cap size and count, avoid trusting browser metadata, isolate storage paths, use signed URLs carefully, and log enough detail to investigate abuse. It pairs naturally with Secure API Route Patterns in Next.js for Safer App Router Backends, Next.js Zod Validation in App Router for Safer Server Actions, Next.js Rate Limiting in App Router for Safer Route Handlers, and Next.js Environment Variables Security for App Router Apps.
The core principle is simple: treat every upload as untrusted until the server has validated the request, the user, the file metadata, the file bytes, and the destination.
Start with the file upload threat model
A secure upload flow should answer these questions before any object becomes public or reaches business logic:
- who is uploading this file?
- what feature is allowed to receive it?
- how large can the file be?
- which file types are allowed?
- where will the object be stored?
- who can read it later?
- what happens if the same user uploads too many files?
Those questions matter because upload bugs rarely come from one missing if statement. They usually come from trusting the browser too much. The client can lie about Content-Type, filenames, extensions, dimensions, and size hints. The server must enforce the real contract.
For sensitive products, separate upload policy by feature. An avatar upload, invoice PDF upload, admin CSV import, and AI document ingestion pipeline should not all share the same rules. Each route should have its own accepted types, size limit, storage prefix, authorization check, and retention policy.
Validate identity and intent before reading large bodies
In App Router route handlers, authenticate the user before doing expensive work. Do not parse a large multipart body, stream to storage, or call an external scanner before you know the request belongs to a valid user who can perform the action.
import { NextResponse } from "next/server";
import { assertUser } from "@/lib/auth/session";
import { checkUploadLimit } from "@/lib/security/rate-limit";
export async function POST(request: Request) {
const user = await assertUser();
const limited = await checkUploadLimit({
userId: user.id,
action: "avatar-upload",
});
if (!limited.allowed) {
return NextResponse.json({ error: "Too many uploads." }, { status: 429 });
}
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file." }, { status: 400 });
}
return handleAvatarUpload(user.id, file);
}
This shape connects App Router upload validation to authentication and rate limiting. A request should not reach file inspection until the app knows which account, tenant, or role owns the operation. For the broader route handler pattern, use the same boundary thinking described in Secure API Route Patterns in Next.js for Safer App Router Backends.
Enforce size, count, and type on the server
Client-side checks improve UX, but they are not security controls. Keep the authoritative limits in server code:
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
function validateUpload(file: File) {
if (file.size <= 0) {
return "File is empty.";
}
if (file.size > MAX_AVATAR_BYTES) {
return "File is too large.";
}
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
return "Unsupported file type.";
}
return null;
}
This is a good first pass, but it is not enough for high-risk uploads. file.type usually comes from request metadata. An attacker can send a script or executable with an image MIME type. For images, inspect the first bytes or use a trusted image processing library that can decode and re-encode the file. For PDFs and office documents, consider malware scanning, private storage, and delayed publication.
For multi-file flows, validate both per-file and per-request limits:
- maximum file size
- maximum total request size
- maximum number of files
- allowed MIME types per field
- allowed extensions only after MIME and byte checks pass
The upload endpoint should fail closed. If the route cannot determine whether the file is valid, reject it.
Normalize names and generate storage keys
Never use the user-provided filename as the storage path. Filenames can contain confusing characters, path traversal attempts, long strings, duplicate names, or private user information. Keep the original name only as sanitized display metadata if the product needs it.
Generate storage keys on the server:
import { randomUUID } from "crypto";
function createObjectKey(input: {
userId: string;
purpose: "avatar" | "support-attachment";
extension: "jpg" | "png" | "webp" | "pdf";
}) {
const id = randomUUID();
return `users/${input.userId}/${input.purpose}/${id}.${input.extension}`;
}
This prevents two important classes of bugs. First, users cannot choose a path that overwrites someone else's object. Second, storage layout stays tied to your authorization model. A tenant-scoped product may use tenants/{tenantId}/users/{userId}/...; a public avatar bucket may use a separate prefix from private support attachments.
Do not place private uploads in a public bucket and hope that obscure URLs are enough. Use private objects by default, then create narrow read paths when the product needs them.
Prefer direct-to-storage uploads for large files
For small avatars or form attachments, uploading through an App Router route can be fine. For larger files, direct-to-storage uploads are usually better. The app authenticates the user, validates the intended upload, creates a short-lived signed URL or POST policy, and lets the browser send bytes directly to object storage.
The server still owns the policy:
import { NextResponse } from "next/server";
import { z } from "zod";
import { assertUser } from "@/lib/auth/session";
const signedUploadSchema = z.object({
filename: z.string().min(1).max(120),
contentType: z.enum(["image/jpeg", "image/png", "image/webp"]),
size: z.number().int().positive().max(2 * 1024 * 1024),
});
export async function POST(request: Request) {
const user = await assertUser();
const input = signedUploadSchema.parse(await request.json());
const objectKey = createObjectKey({
userId: user.id,
purpose: "avatar",
extension: input.contentType === "image/png" ? "png" : "jpg",
});
const signed = await createSignedUploadUrl({
objectKey,
contentType: input.contentType,
maxBytes: input.size,
expiresInSeconds: 60,
});
return NextResponse.json({ objectKey, uploadUrl: signed.url });
}
Signed uploads are not a shortcut around validation. They are a way to move byte transfer out of your Next.js runtime while keeping authorization and upload policy in your app. Keep signed URLs short-lived, bind them to content type and size when the storage provider supports it, and store a pending upload record so the app can verify completion before using the object.
Verify uploads after storage completes
Direct uploads need a second step. A client asking for a signed URL does not prove the file was uploaded correctly, and a completed object does not prove it is safe to publish.
After upload, mark the object as pending until the server verifies it:
export async function confirmUploadedAvatar(userId: string, objectKey: string) {
const object = await storage.headObject(objectKey);
if (!object || object.contentLength > MAX_AVATAR_BYTES) {
throw new Error("Invalid uploaded object.");
}
if (!ALLOWED_IMAGE_TYPES.has(object.contentType ?? "")) {
throw new Error("Invalid uploaded content type.");
}
await db.user.update({
where: { id: userId },
data: { avatarObjectKey: objectKey },
});
}
For user-generated images, re-encode before serving when possible. Re-encoding strips unexpected payloads and gives you predictable dimensions, file type, and compression. For documents, run malware scanning or keep the file private until an asynchronous scanner marks it clean.
This is also where Next.js Webhook Security for App Router Route Handlers can matter. Some storage and scanning providers notify your app through webhooks. Verify those webhook signatures before trusting scan results or changing an object's status.
Keep secrets and storage credentials server-only
Upload flows often involve storage credentials, scanner API keys, webhook secrets, and CDN signing keys. Keep all of them server-only. Do not expose private storage credentials through NEXT_PUBLIC_ variables, client components, source maps, or logs.
Use environment validation for required upload settings:
import "server-only";
import { z } from "zod";
const uploadEnvSchema = z.object({
STORAGE_BUCKET: z.string().min(1),
STORAGE_REGION: z.string().min(1),
STORAGE_ACCESS_KEY_ID: z.string().min(1),
STORAGE_SECRET_ACCESS_KEY: z.string().min(1),
STORAGE_WEBHOOK_SECRET: z.string().min(24),
});
export const uploadEnv = uploadEnvSchema.parse(process.env);
This keeps missing credentials from turning into unsafe fallbacks. It also gives reviewers one obvious place to check whether upload infrastructure is public, private, production-only, or shared across environments.
Use boring responses and useful audit logs
Upload endpoints should avoid detailed error messages that help attackers tune payloads. A user can see "Unsupported file type" or "File is too large." They do not need parser internals, storage bucket names, scanner verdict details, or stack traces.
Logs should capture the operational facts:
console.info("upload.accepted", {
userId,
purpose: "avatar",
objectKey,
size: file.size,
contentType: file.type,
});
Avoid logging raw filenames if they may contain personal data. Avoid logging signed URLs because they are temporary credentials. For abuse investigations, link upload events to user id, tenant id, object key, request id, size, type, and final scan status.
Final takeaway
Next.js file upload security is about controlling untrusted bytes from the first request to the final read path. Authenticate early, rate limit repeated attempts, validate file size and type on the server, generate storage keys yourself, keep private objects private, and verify direct-to-storage uploads after completion.
The strongest secure file uploads Next.js pattern is boring and layered: client checks for UX, server checks for trust, private storage by default, short-lived signed URLs for large files, post-upload verification, and server-only credentials. That keeps App Router upload validation understandable enough to review and strict enough to survive real production traffic.