Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Upload, serve, and delete private Cloudinary files correctly

Sign browser uploads into authenticated Cloudinary assets, check each file before it goes live, serve it through short-lived signed URLs, and delete it with the settings it was stored under.

Cloudinary identifies every asset by three values: its resource type (image, video, or raw), its delivery type (upload, private, or authenticated), and its public ID. An upload, a signed URL, and a delete only reach the same asset if all three match. A delete with the wrong resource type doesn’t fail; Cloudinary reports not found, and the asset stays where it is. Files SDK fixes the resource type and delivery type when you create a cloudinary() adapter. So the pattern is one adapter per combination you use, and a database row that records which one holds each asset.

For files only their owner should see, use the authenticated delivery type. The browser uploads to a staging key with a signature from your server. Your server then moves the file into place and checks its size and format before marking it ready. Downloads go through a route that checks ownership and redirects to a signed URL that expires in a minute. Deleting removes the asset for good, but it can’t recall a link you’ve already handed out.

Before you start

  • A Cloudinary account and an API key and secret, set as CLOUDINARY_URL=cloudinary://<api_key>:<api_secret>@<cloud_name>. The adapter also reads CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET.
  • A Next.js app with an auth library that can resolve the signed-in user on the server, and a table for asset records. The route handlers use only the Web Request and Response, so they port to other frameworks.
  • Written against files-sdk 3.0, cloudinary 2.11, and Next.js 16.4. The adapter behavior below was observed by running the adapter with the real cloudinary SDK’s signing helpers and its network calls stubbed. Cloudinary’s own behavior comes from its documentation, linked where it’s used.
npm install files-sdk cloudinary
pnpm add files-sdk cloudinary
yarn add files-sdk cloudinary
bun add files-sdk cloudinary
nub add files-sdk cloudinary
aube add files-sdk cloudinary

Choose a resource type and a delivery type

The adapter defaults to resourceType: "raw" and type: "upload": arbitrary bytes, served publicly. Private user files need different settings.

Resource type. Cloudinary’s upload reference says image and video public IDs shouldn’t include a file extension, because the format is stored separately, while raw public IDs must include one.

Files resourceType Example key
Photos and other images "image" users/u_1/ast_7f3a
PDFs "image" users/u_1/ast_9c21
Video "video" users/u_1/ast_04be
Other documents (.docx, .csv, .zip) "raw" users/u_1/ast_5d10.docx

PDFs belong under image. That’s how Cloudinary uploads them by default, so page previews work. The exception is a password-protected PDF, which has to be raw.

Delivery type. From Cloudinary’s access control docs:

type The original file Transformed versions What files.url() returns
"upload" (default) Public Public A permanent CDN URL. Passing expiresIn throws Unsupported
"private" Signed URL only Public A signed download URL that expires
"authenticated" Signed URL only Signed URL only A signed download URL that expires

With private, anyone who can guess or find a transformed URL (a resized image, a PDF page rendered as a picture) can fetch it. Use authenticated for files that belong to one user.

One adapter per combination

import { createFiles } from "files-sdk";
import { cloudinary } from "files-sdk/cloudinary";

// Credentials come from CLOUDINARY_URL. Each adapter is pinned to one
// resource type and one delivery type, and only reaches assets stored under both.
export const stores = {
  image: createFiles({
    adapter: cloudinary({ resourceType: "image", type: "authenticated" }),
  }),
  video: createFiles({
    adapter: cloudinary({ resourceType: "video", type: "authenticated" }),
  }),
};

export type StoreName = keyof typeof stores;

export const MAX_BYTES = 20 * 1024 * 1024;

// Formats as Cloudinary reports them, not MIME types.
export const ALLOWED_FORMATS: Record<StoreName, Set<string>> = {
  image: new Set(["jpg", "png", "webp", "gif", "pdf"]),
  video: new Set(["mp4", "mov", "webm"]),
};

export const storeFor = (mime: string): StoreName | null => {
  if (mime.startsWith("image/") || mime === "application/pdf") {
    return "image";
  }
  if (mime.startsWith("video/")) {
    return "video";
  }
  return null;
};

Every cloudinary() adapter calls cloudinary.config() on the SDK’s shared module, so the last adapter created sets the credentials for all of them. That’s harmless here, since both use the same account. To use two Cloudinary accounts in one process, pass separately configured SDK instances through the adapter’s client option.

Record each asset

The browser only sees an asset ID. Your database maps that ID to an owner, a store, and a key:

export interface Asset {
  id: string; // "ast_…", what the browser sees
  ownerId: string;
  store: StoreName; // which adapter holds it
  stagingKey: string; // where the browser uploads: "staging/ast_…"
  key: string; // where it lives once accepted: "users/<ownerId>/ast_…"
  status: "pending" | "ready" | "rejected";
  size?: number;
  format?: string;
}

This guide assumes lib/assets.ts exports that type and a few queries against your database: createAsset, getAsset(id), markReady(id, { size, format }), markRejected(id), and deleteAsset(id).

Storing store is not optional. The adapter’s delete() sends Cloudinary’s destroy with the adapter’s resource type and delivery type. When Cloudinary answers { result: "not found" }, the adapter resolves as if the delete worked. A delete through the wrong adapter looks like a success, and the row is the only record of which adapter is right.

Sign the upload

import { createAsset } from "@/lib/assets";
import { getSession } from "@/lib/auth";
import { MAX_BYTES, storeFor, stores } from "@/lib/cloudinary";

export async function POST(request: Request) {
  const session = await getSession(request.headers);
  if (!session) {
    return new Response("Sign in to upload", { status: 401 });
  }

  const { type, size } = (await request.json()) as {
    type: string;
    size: number;
  };
  const store = storeFor(type);
  if (!store) {
    return new Response("Unsupported file type", { status: 415 });
  }
  // The browser's claim. The real check runs after the upload.
  if (size > MAX_BYTES) {
    return new Response("File too large", { status: 413 });
  }

  const id = `ast_${crypto.randomUUID()}`;
  const stagingKey = `staging/${id}`;
  const signed = await stores[store].signedUploadUrl(stagingKey, {
    expiresIn: 3600,
  });
  if (signed.method !== "POST") {
    throw new Error("Expected a Cloudinary form upload");
  }

  await createAsset({
    id,
    key: `users/${session.user.id}/${id}`,
    ownerId: session.user.id,
    stagingKey,
    status: "pending",
    store,
  });

  return Response.json({ fields: signed.fields, id, url: signed.url });
}

getSession stands in for your auth library. This guide assumes it takes request headers and returns { user: { id: string } } or null.

For the image store, signedUploadUrl returned:

{
  "method": "POST",
  "url": "https://api.cloudinary.com/v1_1/<cloud_name>/image/upload",
  "fields": {
    "api_key": "<api_key>",
    "public_id": "staging/ast_…",
    "signature": "…",
    "timestamp": "1791596503",
    "type": "authenticated"
  }
}

The signature covers public_id, timestamp, and type, and nothing else. Recomputing it with the SDK’s api_sign_request over those three values gave the same signature. That has three consequences:

  • It lasts an hour, whatever you ask for. Cloudinary accepts a signature for one hour from its timestamp. The adapter stamps the current time, so expiresIn changes nothing: 300 and 86400 produced identical fields.
  • It can’t limit size or type. signedUploadUrl throws Unsupported if you pass maxSize, a positive minSize, or contentType, because there’s no field in the signature to carry them. Any extra form field, such as an upload_preset, also breaks the signature, because Cloudinary checks every parameter except file, cloud_name, resource_type, and api_key.
  • It can be reused. Signed uploads overwrite by default, so the same fields can replace the file at that public ID as often as the browser likes until the hour is up.

The last point is why the browser gets a staging key. If it got a signature for the final key, it could replace a file your server had already checked with anything else, for the rest of the hour.

The gateway isn’t a good fit here. The Cloudinary adapter can’t bind a content type into a signature, so for any file with a type, the gateway’s presign falls back to proxying the upload through your server. The adapter then buffers the whole body in memory. The gateway also names files <uuid>.<ext>, and image and video public IDs shouldn’t carry an extension.

Upload from the browser

"use client";

async function uploadFile(file: File): Promise<string> {
  const res = await fetch("/api/uploads", {
    body: JSON.stringify({ size: file.size, type: file.type }),
    headers: { "Content-Type": "application/json" },
    method: "POST",
  });
  if (!res.ok) {
    throw new Error(await res.text());
  }
  const { id, url, fields } = (await res.json()) as {
    id: string;
    url: string;
    fields: Record<string, string>;
  };

  // Send the signed fields exactly as returned, plus the file.
  const form = new FormData();
  for (const [name, value] of Object.entries(fields)) {
    form.append(name, value);
  }
  form.append("file", file);
  const sent = await fetch(url, { body: form, method: "POST" });
  if (!sent.ok) {
    throw new Error(`Cloudinary refused the upload (${sent.status})`);
  }

  const done = await fetch(`/api/uploads/${id}/complete`, { method: "POST" });
  if (!done.ok) {
    throw new Error(await done.text());
  }
  return id;
}

export function UploadButton({ onUploaded }: { onUploaded: () => void }) {
  return (
    <input
      accept="image/*,application/pdf,video/*"
      onChange={async (event) => {
        const file = event.target.files?.[0];
        event.target.value = "";
        if (file) {
          await uploadFile(file);
          onUploaded();
        }
      }}
      type="file"
    />
  );
}

The upload URL already names the resource type (/image/upload), so post to it exactly as returned. Cloudinary’s client-side upload docs use the same fetch and FormData pattern. Cloudinary’s response includes the asset’s details, but the server doesn’t trust them; it looks the asset up itself.

Check the file and move it into place

import { FilesError } from "files-sdk";

import { getAsset, markReady, markRejected } from "@/lib/assets";
import { getSession } from "@/lib/auth";
import { ALLOWED_FORMATS, MAX_BYTES, stores } from "@/lib/cloudinary";

export async function POST(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await getSession(request.headers);
  const { id } = await params;
  const asset = await getAsset(id);
  if (!session || !asset || asset.ownerId !== session.user.id) {
    return new Response("Not found", { status: 404 });
  }
  if (asset.status !== "pending") {
    return new Response("Already completed", { status: 409 });
  }

  const files = stores[asset.store];
  try {
    // Move first: the browser's signature can't touch the final key.
    await files.move(asset.stagingKey, asset.key);
  } catch (error) {
    if (FilesError.wrap(error).code === "NotFound") {
      return new Response("Upload not found", { status: 404 });
    }
    throw error;
  }

  // contentType is "image/<format>" or "video/<format>", e.g. "image/pdf".
  const info = await files.head(asset.key);
  const format = info.contentType.split("/")[1] ?? "";
  if (info.size > MAX_BYTES || !ALLOWED_FORMATS[asset.store].has(format)) {
    await files.delete(asset.key);
    await markRejected(asset.id);
    return new Response("File rejected", { status: 422 });
  }

  await markReady(asset.id, { format, size: info.size });
  return Response.json({ id: asset.id });
}

The order matters. files.move() is Cloudinary’s native rename (the adapter sends invalidate: true and overwrite: true), which keeps the same asset rather than uploading a copy. Once the asset is at users/…, the staging signature can no longer change it, so the head() that follows describes the bytes that will be served. If you checked first and moved second, the browser could swap the file in between.

head() builds contentType from Cloudinary’s stored resource type and format. A PDF stored as an image comes back as image/pdf, and a JPEG as image/jpg. Neither is a real MIME type, so the check compares Cloudinary’s format names. Cloudinary detects the format from the file’s bytes, not from its name or the type the browser claimed.

Serve downloads through signed URLs

import { getAsset } from "@/lib/assets";
import { getSession } from "@/lib/auth";
import { stores } from "@/lib/cloudinary";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await getSession(request.headers);
  if (!session) {
    return new Response("Sign in to download", { status: 401 });
  }
  const { id } = await params;
  const asset = await getAsset(id);
  // One answer for "no such asset" and "not yours".
  if (!asset || asset.ownerId !== session.user.id || asset.status !== "ready") {
    return new Response("Not found", { status: 404 });
  }

  const url = await stores[asset.store].url(asset.key, { expiresIn: 60 });
  return new Response(null, {
    headers: { "Cache-Control": "private, no-store", Location: url },
    status: 302,
  });
}

For an authenticated image, url() made one Admin API resource lookup to read the stored format, then returned a URL of this shape:

https://api.cloudinary.com/v1_1/<cloud_name>/image/download?timestamp=…&public_id=users%2Fu_1%2Fast_9c21&format=pdf&type=authenticated&expires_at=…&signature=…&api_key=…

That’s Cloudinary’s private_download_url, signed with expires_at set from expiresIn. Cloudinary describes it as an authenticated API request made each time the file is downloaded, not a cached CDN copy. Within its 60 seconds, the URL works for anyone who holds it. Linking to the route rather than the URL means each click gets a fresh ownership check.

url() throws Unsupported if you pass responseContentDisposition; Cloudinary has no per-request override for it.

Skip the Admin API lookup

Each url() call on a private or authenticated asset costs one Admin API request. Cloudinary’s Admin API limits are 500 requests an hour on the free plan, with paid plans starting at 2,000. Upload API calls (uploads, renames, deletes) don’t count. A busy download route can run out. The format is already stored on the row, so you can sign the URL locally with the SDK through files.raw:

if (!asset.format) {
  throw new Error(`asset ${asset.id} has no stored format`);
}
const url = stores[asset.store].raw.utils.private_download_url(
  asset.key,
  asset.format,
  {
    expires_at: Math.floor(Date.now() / 1000) + 60,
    resource_type: asset.store, // "image" or "video", matching the adapter
    type: "authenticated",
  }
);

That produced the same URL shape without any request to Cloudinary. The helper signs with the credentials the adapter configured on the shared SDK module. It also takes an attachment: true flag, which it signs into the URL and which the adapter’s url() can’t pass.

Delete and what it revokes

import { deleteAsset, getAsset } from "@/lib/assets";
import { getSession } from "@/lib/auth";
import { stores } from "@/lib/cloudinary";

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await getSession(request.headers);
  const { id } = await params;
  const asset = await getAsset(id);
  if (!session || !asset || asset.ownerId !== session.user.id) {
    return new Response("Not found", { status: 404 });
  }

  // The store recorded at upload, so the resource and delivery types match.
  await stores[asset.store].delete(asset.key);
  await deleteAsset(asset.id);
  return new Response(null, { status: 204 });
}

The adapter sends destroy with the adapter’s resource_type and type and invalidate: true. Delete the asset before the row: if the row delete fails, retrying is safe, because a second destroy for a missing asset resolves too.

What deletion takes away, and when:

  • Signed download URLs you’ve already issued stop finding the file once it’s gone, since each one is a fresh request to Cloudinary’s API. Until then, they work for anyone who has them, up to their expiry. Keep expiresIn short. This guide didn’t run against a live account, so it can’t tell you the exact status code an expired or orphaned link returns.
  • CDN copies matter for upload assets and for the public transformed versions of private ones. invalidate: true asks Cloudinary to clear cached copies of the asset and its transformed versions. Cloudinary’s upload reference says invalidation takes from a few seconds to a few minutes to propagate. Don’t promise users instant removal of public files.

Sweep abandoned uploads

A browser can stop after the upload and never call complete, or keep using its signature to post more files to the staging key. Nothing references those assets. Delete them once the signature that created them has expired:

import { stores } from "@/lib/cloudinary";

// Signatures last an hour, so nothing older than two can still change.
const cutoff = Date.now() - 2 * 60 * 60 * 1000;

for (const files of Object.values(stores)) {
  for await (const file of files.listAll({ limit: 500, prefix: "staging/" })) {
    if ((file.lastModified ?? 0) < cutoff) {
      await files.delete(file.key);
    }
  }
}

Run it on a schedule, and mark pending rows older than the cutoff as rejected in the same job. Each page listAll() fetches is one Admin API resources request. The adapter’s default page is 100 assets, and limit: 500 is Cloudinary’s maximum, so a large backlog still uses up part of the hourly limit.

Instead of relying on the browser to call complete, you can react to Cloudinary’s own upload notifications. Cloudinary events shows how files-sdk/events receives and verifies them.

Limits and tradeoffs

  • Bodies are buffered. Server-side upload() reads the whole body into memory before handing it to Cloudinary’s upload_stream, and download() reads the whole response. Let browsers upload directly and redirect downloads, as above, for anything large.
  • Admin API requests are counted. head(), exists(), list(), download(), and url() on private or authenticated assets all use the Admin API. Past the limit, Cloudinary answers HTTP 420. The adapter turns that into a Provider error, the code Files SDK retries when you configure retries, and retrying a rate limit only uses more of it.
  • copy() won’t work on protected assets. Cloudinary has no native copy, so the adapter asks Cloudinary to upload from the source asset’s delivery URL. For the authenticated image store, that URL was the unsigned https://res.cloudinary.com/<cloud_name>/image/authenticated/v1/<key>, and Cloudinary serves authenticated and private originals only through signed URLs. Use move(), which is a native rename.
  • One account per process unless you pass client. The adapter configures the SDK’s shared module.
  • Size and type are checked after the upload. The signature can’t carry them, so a large or unwanted file is stored, then rejected. Cloudinary’s plan limits still cap the maximum file size. Enforce file-size and content-type limits on presigned uploads compares how other providers handle this.
  • Revocation isn’t instant. See Delete and what it revokes.

Troubleshooting

cloudinary: `maxSize` is not supported for signed upload URLs… (or the same for minSize or contentType). Something passed a limit to signedUploadUrl(). Remove it and check the file in the complete step.

Cloudinary rejects the form with a signature error. A field was changed or added after signing, or the signature is more than an hour old. Send fields exactly as returned, add only file, and get a new signature for each upload.

NotFound from complete. The asset isn’t at the staging key under this adapter’s resource and delivery types. Check that the browser posted to the url the server returned, which includes the resource type, and that the signing and completing routes use the same store.

cloudinary: cannot mint signed URL for "…" — resource has no format.… A private or authenticated asset with no stored format, typically a raw file. See the warning under Choose a resource type and a delivery type.

A delete succeeds, but the asset is still in the Media Library. The delete went through an adapter whose resource type or delivery type doesn’t match the asset. Cloudinary answered not found, and the adapter resolved anyway. Delete through the store recorded on the row.

Provider errors from head(), url(), or list() under load. Cloudinary answers HTTP 420 once the Admin API’s hourly limit is used up. Sign download URLs locally as in Skip the Admin API lookup, and spread out sweeps.

PDFs upload but won’t deliver on a free account. Cloudinary blocks delivery of PDF and ZIP files on free accounts by default. Turn on Allow delivery of PDF and ZIP files in the product environment’s Security settings.

Two adapters seem to use the wrong credentials. Each cloudinary() call reconfigures the SDK’s shared module. Use one account per process, or pass separately configured instances through client.

Last updated on

Was this page helpful?