Skip to content

RESOURCES / BLOG

Secure Image Uploads in Next.js With Signed Uploads and Cloudinary

Stop exposing your API Secret. Here’s how to do image uploads the right way.

Production apps need to prove that an upload request came from your app, not a third party. The way to do that with Cloudinary is a server-generated signature. Your server signs the upload parameters with your API Secret before the browser sends the file to Cloudinary.

This guide walks you through that pattern end to end. You’ll build a production-ready secure upload system where:

  • Your CLOUDINARY_API_SECRET never touches the browser.
  • Every upload is authenticated with a server-side SHA-256 signature.
  • A live gallery fetches images directly from Cloudinary.
  • Images are transformed, optimised, and delivered at CDN speed.

No prior Cloudinary experience needed.

Signed upload flow

1. Browser requests a signature.
   Request: POST /api/sign

2. Next.js signs the upload parameters.
   The API Secret stays on the server.

3. Browser receives the signature.
   Response: { signature, apiKey }

4. Browser uploads the image to Cloudinary.
   The image goes directly to Cloudinary's CDN.

5. Cloudinary verifies the signature.
   If valid, Cloudinary stores the image.

Result:
Your server signs the request, but it never handles the image file.

The server signs. The browser uploads directly to Cloudinary’s CDN. Your server is never in the file-transfer path, so it stays fast even under load.

npx create-next-app@latest nextjs-secure-image-uploads \
  --typescript \
  --tailwind \
  --app \
  --src-dir

cd nextjs-secure-image-uploads
Code language: CSS (css)

Install the packages you need:

npm install cloudinary next-cloudinary sonner
npx shadcn@latest init
Code language: CSS (css)
  • cloudinary, Node SDK for server-side signing, and the Admin API.
  • next-cloudinary, React components like CldUploadWidget and CldImage built for Next.js.
  • sonner, toast notifications.

New to Cloudinary? Sign up free, 25 monthly credits.

An upload preset is a saved set of upload rules. It can define the folder, transformations, and allowed formats.

Setting it to Signed means Cloudinary will reject any upload that does not carry a valid server-generated signature.

  1. Log in to the Cloudinary Console.
  2. Click Settings (⚙️) → UploadUpload presets.
  3. Click Add upload preset.
  4. Fill in:
Field Value
Preset name nextjs-secure-uploads
Signing mode Signed
Folder nextjs-secure-demo

Click Save.

Go to Dashboard, then copy your Cloud name, API key, and API secret.

Create .env.local at the project root.

Never commit this file.

NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name
NEXT_PUBLIC_CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=nextjs-secure-uploads

The rule is simple. Variables prefixed with NEXT_PUBLIC_ are safe to expose because they end up in browser JavaScript.

CLOUDINARY_API_SECRET has no prefix, so Next.js keeps it server-only.

This is the heart of the whole system. One small Route Handler keeps your secret safe.

src/app/api/sign/route.ts

import { v2 as cloudinary } from "cloudinary";

export async function POST(request: Request) {
  const { paramsToSign } = await request.json();

  const signature = cloudinary.utils.api_sign_request(
    paramsToSign,
    process.env.CLOUDINARY_API_SECRET!
  );

  return Response.json({
    signature,
    apiKey: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY,
  });
}
Code language: JavaScript (javascript)

That’s it. The widget calls this endpoint. The server signs the parameters, then sends the signature back to the browser.

The secret stays on the server, always.

CldUploadWidget from next-cloudinary handles the upload UI. You just tell it where to get a signature.

src/components/SecureUpload.tsx

import { CldUploadWidget } from "next-cloudinary";

<CldUploadWidget
  signatureEndpoint="/api/sign"
  uploadPreset={process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET}
  options={{
    sources: ["local", "url", "camera"],
    multiple: true,
    maxFileSize: 10_000_000,
    clientAllowedFormats: ["jpg", "png", "webp", "avif"],
  }}
  onSuccess={(results) => {
    const info = results.info;

    // info.public_id
    // info.secure_url
    // info.width
    // info.height
  }}
>
  {({ open }) => (
    <button type="button" onClick={() => open()}>
      Upload Image
    </button>
  )}
</CldUploadWidget>;
Code language: PHP (php)

The widget automatically calls /api/sign before each upload. If the signature is missing or wrong, Cloudinary refuses the file.

Once images are in Cloudinary, you can search them server-side using the Admin Search API.

src/app/api/gallery/route.ts

const result = await cloudinary.search
  .expression("folder=nextjs-secure-demo")
  .sort_by("created_at", "desc")
  .with_field("tags")
  .max_results(100)
  .execute();
Code language: JavaScript (javascript)

This returns every image in your folder with full metadata, including dimensions, format, file size, tags, and public ID.

Your gallery component maps over result.resources and passes them to CldImage.

CldImage is a drop-in replacement for Next.js <Image>. It works with Cloudinary’s transformation URL syntax.

import { CldImage } from "next-cloudinary";

<CldImage
  src="nextjs-secure-demo/my-photo"
  width={800}
  height={600}
  format="auto"
  quality="auto"
  crop={{
    type: "fill",
    gravity: "auto",
  }}
  alt="My photo"
/>;
Code language: JavaScript (javascript)

Here’s what those values do:

  • src uses the Cloudinary public ID.
  • format="auto" lets Cloudinary pick WebP, AVIF, or another best format.
  • quality="auto" balances file size and visual quality.
  • gravity="auto" uses smart crop to focus on the key part of the image.

format="auto" and quality="auto" alone can cut image weight by 50 to 80% with no visual difference. The image is delivered from the nearest Cloudinary CDN edge.

Every transformation happens on Cloudinary’s CDN. No image-processing libraries in your app. No extra CPU on your server.

You only change URL parameters.

src/components/CloudinaryGallery.tsx

import { getCldImageUrl } from "next-cloudinary";

// Grayscale
getCldImageUrl({
  src: publicId,
  effects: [{ grayscale: true }],
});

// Sepia
getCldImageUrl({
  src: publicId,
  effects: [{ sepia: "60" }],
});

// Auto-enhance: brightness, contrast, and saturation
getCldImageUrl({
  src: publicId,
  effects: [{ improve: true }],
});

// Sharpen
getCldImageUrl({
  src: publicId,
  effects: [{ sharpen: "80" }],
});

// Vignette
getCldImageUrl({
  src: publicId,
  effects: [{ vignette: "50" }],
});
Code language: JavaScript (javascript)

The demo app exposes these as one-click presets in the gallery lightbox. You can swap effects at runtime with zero re-uploads.

Cloudinary’s add-on marketplace plugs AI and third-party services directly into your media pipeline.

Add-on What it does
Cloudinary AI Background Removal Remove backgrounds in one URL parameter
Google Auto Tagging Auto-tag images using Google Vision AI
Amazon Rekognition Moderate uploads for explicit content
Upscale 4× upscale with AI super-resolution
Viesus Auto Enhance Automatic professional-grade photo enhancement

Enable them in your Cloudinary console. Then call them via URL:

// Background removal, requires add-on
getCldImageUrl({
  src: publicId,
  removeBackground: true,
});
Code language: JavaScript (javascript)

No new SDK. No extra API call. Just a URL.

The exact same signed upload pattern works for video. Change one option:

<CldUploadWidget
  signatureEndpoint="/api/sign"
  uploadPreset="your-video-preset"
  options={{
    resourceType: "video",
    sources: ["local", "camera", "url"],
  }}
>
  ...
</CldUploadWidget>
Code language: PHP (php)

Display the uploaded video with CldVideoPlayer. You get adaptive streaming, subtitles, and Cloudinary’s transformation suite.

import { CldVideoPlayer } from "next-cloudinary";

<CldVideoPlayer
  src="your-video-public-id"
  width={1280}
  height={720}
  transformation={{
    quality: "auto",
    fetch_format: "auto",
  }}
/>;
Code language: JavaScript (javascript)
npm install -g vercel
vercel

In your Vercel project, go to Settings → Environment Variables. Add the same four variables from your .env.local.

Mark CLOUDINARY_API_SECRET as sensitive.

That is your only production checklist item for security. The signing endpoint and secret handling are already correct.

By following this guide, you’ll have:

  • A signed upload flow, no public presets and no exposed secrets.
  • A server-side gallery, powered by Cloudinary’s Admin Search API.
  • On-the-fly transformations, format, quality, and effects through URLs.
  • A UI built with shadcn/ui + Tailwind v4 that works in dark mode.

This isn’t a prototype pattern. This is how production apps should handle media from day one.

Ready to start building with Cloudinary? Sign up for a free account today.

Live demo: nextjs-secure-image-uploads.vercel.app

Why is it dangerous to perform unsigned uploads on production websites?

Unsigned uploads expose your upload presets directly to the client, which can allow unauthorized users to upload random files to your cloud storage. Production applications should always require server-side signatures to validate and restrict upload privileges securely.

How does the server-side signing flow keep the Cloudinary API secret safe?

The private API secret remains solely on your secure backend environment. When a client initiates an upload, the browser requests a temporary cryptographic signature from a Next.js API route, which signs the upload parameters using the secret without ever exposing the key to the client.

What are the performance benefits of uploading directly to Cloudinary instead of proxying through a Next.js server?

Uploading files directly to Cloudinary offloads the high-bandwidth file transfer workload from your server to a global delivery network. Your server only processes a lightweight cryptographic signature, which keeps your server responsive and fast even under heavy concurrent traffic.

How do Cloudinary transformations improve Next.js page performance?

You can utilize next-cloudinary components to automatically compress quality and serve the most efficient modern format like AVIF or WebP on the fly. This dynamic optimization reduces payload sizes by 50% to 80% without any visible loss in visual quality.

Can I use the exact same signed upload architecture for video assets?

Yes. The signing API endpoint functions identically for videos. You only need to configure the client-side upload widget to handle video resource types and render the resulting upload with a dedicated video player component.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free