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
Browser
Asks your backend to upload a file
Your backend
Authorises the user, calls presign
Browser PUT
Raw bytes to the single-use uploadUrl
Your backend
Calls confirm with the upload id
Asset ready
pending until confirmed
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 });
});type presignRequest struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
ContentHash string `json:"contentHash"`
}
func presignHandler(client *dreep.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := currentUser(r) // your own auth
if !ok {
http.Error(w, "unauthorised", http.StatusUnauthorized)
return
}
var in presignRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !allowedType(in.ContentType) || in.SizeBytes > maxBytes {
http.Error(w, "file not allowed", http.StatusBadRequest)
return
}
upload, err := client.PresignUpload(r.Context(), dreep.PresignOptions{
Filename: in.Filename,
ContentType: in.ContentType,
SizeBytes: in.SizeBytes,
ContentHash: in.ContentHash,
Destination: dreep.Destination{Folder: "users/" + user.ID},
})
if err != nil {
http.Error(w, "presign failed", http.StatusBadGateway)
return
}
recordPendingUpload(r.Context(), upload.ID, user.ID)
json.NewEncoder(w).Encode(upload) // id, uploadUrl, alreadyExists
}
}
// Confirm handler: check the upload belongs to this user, then
// asset, err := client.ConfirmUpload(r.Context(), id, nil)# 1. Presign (server-side, with your API key)
curl -X POST https://api.dreep.cloud/api/v1/upload/presign \
-H "Authorization: Bearer $DREEP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"receipt.pdf","contentType":"application/pdf","sizeBytes":482113,"folder":"users/42"}'
# 2. PUT the bytes (no API key; headers must match step 1)
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @receipt.pdf
# 3. Confirm (server-side)
curl -X POST "https://api.dreep.cloud/api/v1/upload/$UPLOAD_ID/confirm" \
-H "Authorization: Bearer $DREEP_API_KEY"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
| Concern | Dreep | You |
|---|---|---|
| API key exposure | Upload URL needs no API key; key stays server-side | Keep the key out of client code and logs |
| Who may upload | Not handled | Authorise each user before calling presign |
| File type | Rejects types outside the allow-list | Narrow it further for your use case |
| File size | Rejects files over 500 MB | Set your own lower per-feature limit |
| Declared vs actual upload | Content-Type and Content-Length must match presign | Send the real file type and size |
| URL reuse | Upload URL is single-use and expires after 10 minutes | Request a fresh URL per file |
| Ownership of an upload | Not handled | Map upload id to user before confirm |
| Per-user quotas and rate limits | Not handled; plan-level limits only | Enforce in your presign endpoint |
| Content moderation | Not handled | Review or classify content yourself |
| Malware scanning | Not handled | Scan with your own tooling if you accept documents |
| Who can view the file | Public, Private or Signed folder access | Choose the folder and expiry |
Limits and accepted file types
| Kind | Accepted types | Behaviour |
|---|---|---|
| Image | JPEG, PNG, WebP, GIF, TIFF, BMP, SVG, HEIC, HEIF | Resize, crop and convert by URL |
| Video | MP4, MOV, WebM | Transcode, trim, HLS, thumbnails |
| Document and data | PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, CSV, TXT, JSON, ZIP | Stored and served as-is |
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
Image CDN API
Resize and convert uploaded images by URL.
Video transcoding API
Turn uploaded videos into MP4, WebM, HLS or GIF.
Image transformations for Cloudflare R2
Land presigned uploads in your own R2 bucket.
Bring your own bucket
Store originals in your own S3-compatible bucket.
SDKs
presignUpload and confirmUpload in JavaScript and Go.
Pricing
Free, Starter and Pro plans and add-ons.
Signed URL docs
How signatures and expiry work.
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.