Stop exposing your API Secret. Here’s how to do image uploads the right way.
- Live demo: nextjs-secure-image-uploads.vercel.app
- Full source: github.com/musebe/nextjs-secure-image-uploads
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_SECRETnever 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
CldUploadWidgetandCldImagebuilt 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.
- Log in to the Cloudinary Console.
- Click Settings (⚙️) → Upload → Upload presets.
- Click Add upload preset.
- 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.
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.
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:
-
srcuses 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.
- Full source code: github.com/musebe/nextjs-secure-image-uploads
Live demo: nextjs-secure-image-uploads.vercel.app
- Cloudinary Signed Uploads documentation
- next-cloudinary CldUploadWidget
- Cloudinary Transformation reference
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.