How to Implement a Secure File Upload API in Next.js Using NextAuth and Cloudinary

A complete, step‑by‑step guide to building a production‑ready file upload endpoint in Next.js. Learn how to authenticate users with NextAuth, validate and sanitize uploads, store files securely on Cloudinary, and protect the route against common attacks.

Secure File Upload API in Next.js-Project Overview

Modern web applications often need to accept user‑generated files — avatars, documents, images, or videos. Handling uploads securely requires authentication, strict validation, and a reliable storage backend. This tutorial shows how to combine Next.js API routes, NextAuth.js for session management, and Cloudinary for scalable media delivery.

Prerequisites

  • Node.js 18+ installed
  • A Next.js 14 (App Router) project created with create-next-app
  • Accounts on NextAuth.js and Cloudinary
  • Basic familiarity with TypeScript, React, and REST APIs

Setting Up NextAuth

NextAuth handles user sessions via secure HTTP‑only cookies. We’ll use the credentials provider for simplicity, but any provider works.

1. Install Dependencies

npm i next-auth@beta cloudinary formidable

2. Configure Authentication Options

Create lib/auth.ts (or .js if you prefer plain JS).

import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { compare } from "bcryptjs"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null
        const user = await prisma.user.findUnique({ where: { email: credentials.email } })
        if (!user) return null
        const isValid = await compare(credentials.password, user.passwordHash)
        if (!isValid) return null
        return { id: user.id, email: user.email, name: user.name }
      },
    }),
  ],
  session: { strategy: "jwt" },
  callbacks: {
    async jwt({ token, user }) {
      if (user) token.id = user.id
      return token
    },
    async session({ session, token }) {
      if (session.user) (session.user as any).id = token.id
      return session
    },
  },
  pages: { signIn: "/auth/signin" },
})

3. Add API Route Handlers

In app/api/auth/[...nextauth]/route.ts export the handlers:

import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers

Creating the Upload API Route

Next.js App Router uses route handlers under app/api/. We’ll create app/api/upload/route.ts.

1. Parse Multipart Form Data

Use formidable (or the native Request.formData() in Next 14) to read files. The example below uses formidable for streaming large files.

import { IncomingForm, Fields, Files } from "formidable"
import { Readable } from "stream"
import { NextRequest, NextResponse } from "next/server"

function parseForm(req: NextRequest): Promise {
  const form = new IncomingForm({ multiples: false, keepExtensions: true })
  const contentType = req.headers.get("content-type") ?? ""
  const buffer = await req.arrayBuffer()
  const stream = Readable.from(Buffer.from(buffer))
  // @ts-ignore - attach headers for formidable
  stream.headers = { "content-type": contentType }
  return new Promise((resolve, reject) => {
    form.parse(stream, (err, fields, files) => {
      if (err) reject(err)
      else resolve({ fields, files })
    })
  })
}

2. Validate the Session

Only authenticated users may upload. Use auth() from NextAuth.

import { auth } from "@/lib/auth"

const session = await auth()
if (!session?.user) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

3. File Validation Rules

  • Allowed MIME types: image/jpeg, image/png, application/pdf
  • Maximum size: 5 MB (adjust per requirement)
  • Sanitize filename to avoid path traversal
const ALLOWED_TYPES = ["image/jpeg", "image/png", "application/pdf"]
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB

const file = files.file?.[0] // assuming single file field named "file"
if (!file) {
  return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.mimetype ?? "")) {
  return NextResponse.json({ error: "Unsupported file type" }, { status: 400 })
}
if (file.size > MAX_SIZE) {
  return NextResponse.json({ error: "File exceeds size limit" }, { status: 400 })
}
// Optional: rename to a safe UUID
import { v4 as uuidv4 } from "uuid"
const safeName = `${uuidv4()}-${file.originalFilename?.replace(/[^a-zA-Z0-9._-]/g, "")}`

Uploading to Cloudinary

Cloudinary offers signed uploads, automatic transformations, and CDN delivery. Store credentials in environment variables.

1. Environment Variables

# .env.local
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret

2. Cloudinary Helper

import { v2 as cloudinary } from "cloudinary"

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
})

export async function uploadToCloudinary(
  filePath: string,
  options: Record = {}
) {
  return cloudinary.uploader.upload(filePath, {
    resource_type: "auto",
    folder: "user_uploads",
    use_filename: true,
    unique_filename: false,
    ...options,
  })
}

3. Perform the Upload

import { uploadToCloudinary } from "@/lib/cloudinary"
import fs from "fs/promises"

const uploadResult = await uploadToCloudinary(file.filepath, {
  public_id: safeName,
  context: `userId=${session.user.id}`, // useful for later queries
})

// Clean up temporary file
await fs.unlink(file.filepath).catch(() => {})

return NextResponse.json({
  url: uploadResult.secure_url,
  publicId: uploadResult.public_id,
  format: uploadResult.format,
  width: uploadResult.width,
  height: uploadResult.height,
})

Securing the Endpoint

Rate Limiting

Prevent abuse by limiting uploads per user. A simple in‑memory store works for a single instance; for production use Redis.

import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 uploads per minute
})

const { success } = await ratelimit.limit(session.user.id)
if (!success) {
  return NextResponse.json({ error: "Too many requests" }, { status: 429 })
}

Content‑Security‑Policy & CORS

Set appropriate headers in next.config.js or via middleware.

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/api/upload",
        headers: [
          { key: "Content-Security-Policy", value: "default-src 'self'; img-src data: https://res.cloudinary.com;" },
          { key: "Access-Control-Allow-Origin", value: process.env.NEXT_PUBLIC_APP_URL ?? "*" },
          { key: "Access-Control-Allow-Methods", value: "POST, OPTIONS" },
          { key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" },
        ],
      },
    ]
  },
}

Virus Scanning (Optional)

Integrate a service like ClamAV or a cloud‑based scanner before forwarding to Cloudinary. This step is omitted for brevity but recommended for public upload endpoints.

Handling Errors and Responses

  • Return consistent JSON shape: { data?, error? }
  • Log server‑side errors with a structured logger (e.g., Pino) but never expose stack traces to the client.
  • Use HTTP status codes: 400 for validation, 401/403 for auth, 429 for rate limit, 500 for unexpected failures.
try {
  // … validation & upload logic
} catch (err) {
  console.error("Upload error:", err)
  return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}

Testing the Integration

Unit Tests

Mock auth(), formidable, and Cloudinary SDK. Verify that unauthorized requests receive 401, oversized files receive 400, and successful uploads return a Cloudinary URL.

End‑to‑End Tests

Use Cypress or Playwright to simulate a logged‑in user posting a multipart form to /api/upload. Assert the response contains a valid https://res.cloudinary.com/… URL.

Manual Verification

  1. Start the dev server: npm run dev
  2. Sign in via the NextAuth sign‑in page.
  3. Use a tool like Postman or a simple HTML form targeting /api/upload with enctype="multipart/form-data".
  4. Confirm the file appears in your Cloudinary Media Library under the user_uploads folder.

Deployment Considerations

  • Vercel: API routes run as serverless functions with a 4.5 MB request body limit. For larger files, stream directly to Cloudinary using signed upload URLs (client‑side upload) instead of proxying through the API.
  • Docker / Kubernetes: Increase the body parser limit (bodyParser: { sizeLimit: '10mb' } in next.config.js) and ensure the container has enough ephemeral storage for temporary files.
  • Environment Secrets: Store Cloudinary credentials in the platform’s secret manager; never commit .env.local.
  • Monitoring: Add Sentry or Datadog to capture upload failures and latency spikes.

Common Pitfalls and How to Avoid Them

PitfallWhy It HappensFix
Uploads hang or time outLarge files buffered in memoryStream to disk with formidable or use client‑side signed uploads
Unauthenticated requests succeedForgot to call auth() before processingGuard the handler early; return 401 if no session
File type bypassOnly checking extension, not MIMEValidate file.mimetype and optionally inspect magic bytes
Cloudinary errors not surfacedSwallowing promise rejectionsWrap upload in try/catch and return structured error
Rate limiter ineffective in serverlessIn‑memory store resets per invocationUse Redis‑backed limiter (Upstash, Vercel KV, etc.)

Conclusion

You now have a fully functional, secure Next.js file upload endpoint that authenticates users with NextAuth, validates every payload, stores assets on Cloudinary, and protects against abuse. The pattern scales: swap the storage provider, add virus scanning, or move to client‑side signed uploads for very large files. Keep security hygiene — rotate API secrets, monitor logs, and enforce least‑privilege access on Cloudinary folders.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *