Secure file uploads with presigned URLs

Let users upload straight from the browser without exposing your API key or storage bucket. Your backend checks who the user is, asks Dreep for a single-use upload URL that expires after 10 minutes, and hands it to the browser. The browser PUTs the bytes, your backend confirms, and Dreep checks type, size and headers before the file goes live.

The three-step presigned upload flow

  1. Browser

    Asks your backend to upload a file

  2. Your backend

    Authorises the user, calls presign

  3. Browser PUT

    Raw bytes to the single-use uploadUrl

  4. Your backend

    Calls confirm with the upload id

  5. Asset ready

    pending until confirmed

Your API key stays on your server. The browser only ever holds a URL that works once, for 10 minutes, for one file.

Why not just upload through your API?

Proxying uploads through your own servers means holding open long requests, buffering or streaming large bodies, and paying for the bandwidth twice. The alternative many teams reach for, handing the browser bucket credentials or a long-lived token, is worse: anyone who opens developer tools can reuse it.

A presigned upload splits the job. Your backend makes the decision about who may upload what, which is the part only you can make, and the browser sends bytes directly to an upload URL scoped to exactly one file. The Dreep API key is a full-project credential, so it must never be in browser or mobile code; the JavaScript SDK refuses to construct in a browser for that reason.

Your backend: authorise, then presign

Two small endpoints. The first authorises the user, applies your own rules, and calls presign with a folder scoped to that user. The second checks the upload belongs to the caller and confirms it. Record the upload id against the user at presign time so nobody can confirm someone else's upload.

import express from "express";
import { Dreep } from "dreep";

const dreep = new Dreep({ apiKey: process.env.DREEP_API_KEY });
const app = express();
app.use(express.json());

const ALLOWED = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
const MAX_BYTES = 20 * 1024 * 1024; // your limit, below Dreep's 500 MB

// requireUser is your own auth middleware
app.post("/uploads", requireUser, async (req, res) => {
  const { filename, contentType, sizeBytes, contentHash } = req.body;

  if (!ALLOWED.includes(contentType) || sizeBytes > MAX_BYTES) {
    return res.status(400).json({ error: "File not allowed" });
  }

  const upload = await dreep.presignUpload({
    filename,
    contentType,
    sizeBytes,
    contentHash,
    folder: `users/${req.user.id}`,
  });

  await db.pendingUploads.insert({ id: upload.id, userId: req.user.id });

  res.json({
    id: upload.id,
    uploadUrl: upload.uploadUrl,
    alreadyExists: upload.alreadyExists,
  });
});

app.post("/uploads/:id/confirm", requireUser, async (req, res) => {
  const { id } = req.params;
  const pending = await db.pendingUploads.find({ id, userId: req.user.id });
  if (!pending) return res.status(404).end();

  const asset = await dreep.confirmUpload({ id });
  res.json({ id: asset.id, url: asset.url });
});

The browser: PUT the bytes, then confirm

The PUT must use the same Content-Type and Content-Length you declared at presign time, or it is rejected. Sending a SHA-256 contentHash is optional: if Dreep already holds identical content, the response says alreadyExists and you skip the PUT. Hashing reads the whole file into memory, so skip it for very large files.

const sha256 = async (file) => {
  const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
  return [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
};

const uploadFile = async (file) => {
  const presign = await fetch("/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
      sizeBytes: file.size,
      contentHash: await sha256(file),
    }),
  });
  const { id, uploadUrl, alreadyExists } = await presign.json();

  if (!alreadyExists) {
    // Content-Length is set from the File by the browser
    const put = await fetch(uploadUrl, {
      method: "PUT",
      headers: { "Content-Type": file.type },
      body: file,
    });
    if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
  }

  const confirm = await fetch(`/uploads/${id}/confirm`, { method: "POST" });
  return confirm.json(); // { id, url }
};

What Dreep validates, and what is still yours

ConcernDreepYou
API key exposureUpload URL needs no API key; key stays server-sideKeep the key out of client code and logs
Who may uploadNot handledAuthorise each user before calling presign
File typeRejects types outside the allow-listNarrow it further for your use case
File sizeRejects files over 500 MBSet your own lower per-feature limit
Declared vs actual uploadContent-Type and Content-Length must match presignSend the real file type and size
URL reuseUpload URL is single-use and expires after 10 minutesRequest a fresh URL per file
Ownership of an uploadNot handledMap upload id to user before confirm
Per-user quotas and rate limitsNot handled; plan-level limits onlyEnforce in your presign endpoint
Content moderationNot handledReview or classify content yourself
Malware scanningNot handledScan with your own tooling if you accept documents
Who can view the filePublic, Private or Signed folder accessChoose the folder and expiry
Dreep enforces the upload contract. Business rules about your users stay in your code.

Limits and accepted file types

KindAccepted typesBehaviour
ImageJPEG, PNG, WebP, GIF, TIFF, BMP, SVG, HEIC, HEIFResize, crop and convert by URL
VideoMP4, MOV, WebMTranscode, trim, HLS, thumbnails
Document and dataPDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, CSV, TXT, JSON, ZIPStored and served as-is
Maximum 500 MB per file. Anything outside these types is rejected at presign time.

Where uploads land, and who can read them

Public folders

Anyone with the URL can fetch the file. Right for avatars and listing photos you intend to show.

Signed folders

Only reachable with a time-limited HMAC-SHA256 link your backend generates locally, e.g. dreep.signedUrl(asset, { expiresIn: 900 }). Each folder has a default expiry and you can override it per asset at presign time.

Private folders

No public URL at all. Only project members can view the files in the dashboard. Right for compliance records and internal documents.

Inherited from the path

Folders created on the fly by a path such as users/42/receipts inherit their parent's access mode, so a new subfolder under a Signed folder is never silently public.

Who this is for

  • Apps where users upload avatars, photos, receipts or documents from the browser or a mobile app.
  • Teams that want direct-to-storage uploads without writing bucket policies or CORS rules.
  • Products that need some files public and others behind expiring links.
  • Backends in Node, TypeScript or Go, or anything that can make HTTP requests.

When Dreep is not the right fit

  • You need built-in malware scanning or content moderation. Dreep does not provide either.
  • You need resumable or multipart chunked uploads for files over 500 MB.
  • You want a drop-in upload widget. Dreep gives you the API; the UI is yours.

Frequently asked questions

How do I upload from the browser without exposing my API key?

Your backend authenticates the user and calls POST /api/v1/upload/presign with the API key. It returns an upload id and an uploadUrl. The browser PUTs the file to uploadUrl, which needs no key, and your backend calls confirm. The key never leaves your server.

How long is a presigned upload URL valid?

10 minutes, and it can be used once. Request a new one per file. Treat the URL as opaque; do not parse or reuse it.

What happens if the browser sends a different file type or size?

The PUT is rejected. Content-Type and Content-Length must match the contentType and sizeBytes your backend declared at presign time, so a client cannot swap in a larger file or a different type.

Does Dreep scan uploads for malware?

No. Dreep validates type, size and headers, but does not scan file contents for malware or moderate them. If you accept documents or archives from the public, run your own scanning.

What does the confirm step do?

It checks the bytes landed, reads the file's metadata, applies any transform you passed, and marks the asset ready. Until then it is pending and excluded from media listings. A 409 response means the bytes have not landed yet; retry shortly.

Can I upload into a folder that only signed links can read?

Yes. Name a Signed folder, or a path under one, at presign time. The asset is then only reachable through time-limited signed URLs your backend generates.

Related

Ship browser uploads without leaking your key

Create a project, add two endpoints to your backend, and your users upload directly with single-use URLs.