Direct browser uploads without exposing your bucket or API key
How the presign, PUT, confirm pattern lets browsers upload straight to storage while your backend keeps the keys and decides who may upload what.
· 8 min read
Every app that accepts user photos eventually hits the same fork. Either the file streams through your own server, which then forwards it to storage, or the browser sends it to storage directly. Proxying is simple but your servers carry every byte, hold connections open for slow mobile uploads and need generous body limits. Direct uploads remove that load, but only if you can let a browser write to storage without handing it credentials.
The standard answer is a presigned upload: your backend, which holds the secret, asks the storage service for a short-lived URL that allows exactly one upload, and gives only that URL to the browser. This post walks through the pattern with the Dreep API, in Node and Go, and is specific about which checks Dreep performs and which ones stay with you.
Why not just give the browser a key?
A Dreep API key is a full-project credential: it can upload, list and delete everything in the project. The same is true of most bucket access keys. Anything shipped to a browser or a mobile app can be extracted, so the key must stay on your server. The JavaScript SDK enforces this by throwing if it is constructed in a browser.
A public-write bucket is worse: anyone can fill it with anything. What you want is a capability that is narrow (one file, one destination, one content type and size), short-lived and issued only after your own authorisation check.
The three-step flow
Browser
Asks your backend to upload
Your backend
Checks the user, calls presign
Browser PUT
Raw bytes to the uploadUrl
Your backend
Confirms, saves the asset
- Presign: your backend calls
POST /api/v1/upload/presignwith the filename,contentType,sizeBytesand destination folder. Dreep returns anidand anuploadUrl. - PUT: the browser sends the raw file to
uploadUrlwith a matchingContent-Type. The URL is single-use and valid for 10 minutes, and theContent-TypeandContent-Lengthmust match what was presigned. - Confirm: your backend calls
POST /api/v1/upload/{id}/confirm. Dreep checks the bytes arrived, reads the metadata and marks the assetready. Until then it ispendingand does not appear in media listings.
Keeping confirm on the backend matters. It is the moment your database learns about the file, so it is where you attach the asset to the user, enforce per-user quotas and trigger anything downstream.
The backend in Node
Two routes. The first authorises and presigns; the second confirms. Express is used for brevity, and requireUser and db stand in for your own session middleware and database.
import express from "express";
import { Dreep, DreepConflictError } from "dreep";
const app = express();
app.use(express.json());
const dreep = new Dreep({ apiKey: process.env.DREEP_API_KEY });
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/heic"]);
const MAX_BYTES = 20 * 1024 * 1024; // your limit, well under Dreep's 500 MB
// requireUser is your own session middleware.
app.post("/uploads/presign", requireUser, async (req, res) => {
const { filename, contentType, sizeBytes } = req.body;
if (!ALLOWED_TYPES.has(contentType) || sizeBytes > MAX_BYTES) {
return res.status(400).json({ error: "Unsupported file" });
}
const upload = await dreep.presignUpload({
filename,
contentType,
sizeBytes,
folder: "users/" + req.user.id + "/photos", // decided by the server, never the client
});
// Remember who asked for this upload id.
await db.pendingUploads.insert({ id: upload.id, userId: req.user.id });
res.json({ id: upload.id, uploadUrl: upload.uploadUrl });
});
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).json({ error: "Unknown upload" });
}
try {
const asset = await dreep.confirmUpload({ id });
await db.photos.insert({ userId: req.user.id, assetId: asset.id, url: asset.url });
await db.pendingUploads.delete({ id });
res.json({ url: asset.url });
} catch (error) {
if (error instanceof DreepConflictError) {
// The PUT has not finished landing yet. Let the client retry.
return res.status(409).json({ error: "Upload not finished" });
}
throw error;
}
});Three details carry most of the security. The destination folder is built on the server from the authenticated user, never read from the request body. Each upload id is stored against the user who requested it, so one user cannot confirm, and claim, another user's upload. And the type and size limits are yours, applied before Dreep is ever called.
The browser
No SDK and no secret in the client, just fetch:
const uploadPhoto = async (file) => {
// 1. Ask your backend for a single-use upload URL
const presign = await fetch("/uploads/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
sizeBytes: file.size,
}),
});
if (!presign.ok) throw new Error("Upload refused");
const { id, uploadUrl } = await presign.json();
// 2. PUT the bytes. Content-Type must match what you presigned;
// the browser sets Content-Length from the File.
const put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error("Upload failed");
// 3. Tell your backend, which confirms with Dreep
const confirm = await fetch("/uploads/" + id + "/confirm", { method: "POST" });
if (!confirm.ok) throw new Error("Confirm failed");
return confirm.json();
};fetch does not report upload progress. If you want a progress bar, send step 2 with XMLHttpRequest and listen to xhr.upload.onprogress; steps 1 and 3 can stay as they are. If confirm returns 409, wait a second and call it again: it means the bytes had not settled when you asked.
The backend in Go
The same two handlers with the Go SDK and the standard library router. PendingStore is whatever you use to remember upload ids, usually a table.
var allowed = map[string]bool{"image/jpeg": true, "image/png": true, "image/webp": true}
const maxBytes = 20 << 20
func presignHandler(client *dreep.Client, store PendingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context()) // your auth middleware
var body struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil ||
!allowed[body.ContentType] || body.SizeBytes > maxBytes {
http.Error(w, "unsupported file", http.StatusBadRequest)
return
}
upload, err := client.PresignUpload(r.Context(), dreep.PresignOptions{
ContentType: body.ContentType,
SizeBytes: body.SizeBytes,
Filename: body.Filename,
Destination: dreep.Destination{Folder: "users/" + user.ID + "/photos"},
})
if err != nil {
http.Error(w, "presign failed", http.StatusBadGateway)
return
}
store.Save(r.Context(), upload.ID, user.ID)
json.NewEncoder(w).Encode(map[string]string{"id": upload.ID, "uploadUrl": upload.UploadURL})
}
}
func confirmHandler(client *dreep.Client, store PendingStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
id := r.PathValue("id") // Go 1.22 routing: POST /uploads/{id}/confirm
if !store.BelongsTo(r.Context(), id, user.ID) {
http.NotFound(w, r)
return
}
asset, err := client.ConfirmUpload(r.Context(), id, nil)
switch {
case dreep.IsConflict(err):
http.Error(w, "upload not finished", http.StatusConflict)
return
case err != nil:
http.Error(w, "confirm failed", http.StatusBadGateway)
return
}
store.Delete(r.Context(), id)
json.NewEncoder(w).Encode(map[string]string{"url": asset.URL})
}
}For uploads that start on the server rather than in a browser, such as a batch import, the Go SDK's UploadPresigned runs presign, PUT and confirm in one call and retries confirm while the bytes settle.
Skipping duplicate uploads
If users often upload the same file twice, pass its SHA-256 as contentHash when presigning. When Dreep already stores that content, the response has alreadyExists: true and no uploadUrl, so the client skips the PUT and goes straight to confirm.
// In the browser: hash before presigning to skip duplicate bytes
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
const contentHash = Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
// On the server: pass it through
const upload = await dreep.presignUpload({ filename, contentType, sizeBytes, folder, contentHash });
if (upload.alreadyExists) {
// No uploadUrl. Tell the client to skip the PUT and go straight to confirm.
}Hashing a large file in the browser costs CPU and memory, so this is worth it for photos and documents, less so for multi-hundred-megabyte video.
What Dreep checks, and what stays with you
| Concern | Who handles it |
|---|---|
| File type allow-list (images, MP4/MOV/WebM video, common documents) | Dreep |
| 500 MB per-file ceiling | Dreep |
| Content-Type and Content-Length must match the presign | Dreep, on the PUT |
| Upload URLs are single-use and expire after 10 minutes | Dreep |
| Which of your users may upload, and where | You, before presigning |
| Per-user quotas and tighter size limits | You |
| Content moderation | You |
| Malware scanning of documents | You. Dreep does not scan files for malware. |
If uploads should not be publicly viewable, presign into a signed folder. Folders created from a path inherit their parent's access mode, so users/123/photos under a signed users folder is signed too, and you serve it with short-lived signed URLs.
Failures, retries and mobile networks
Each step fails differently, and the client should treat them differently. A failed presign is an authorisation or validation answer from your own backend, so show the message and stop. A failed PUT is usually the network. Because the upload URL is single-use and expires after 10 minutes, the safe retry is to start again from presign rather than replaying the same URL. A 409 from confirm only means the bytes had not settled; retry confirm itself after a short pause.
On mobile, the same three steps apply from Swift or Kotlin: call your backend for the presign, PUT the file with the platform HTTP client, then call your backend to confirm. Run the PUT in a background-capable transfer (a background URLSession on iOS, WorkManager on Android) so a large video survives the user switching apps. The upload is a single PUT, not a resumable multipart protocol, so on a network that drops repeatedly, keep files small with client-side compression before uploading.
The upload URL is on cdn.dreep.cloud and accepts cross-origin PUT requests from browsers, so there is no bucket CORS policy for you to maintain. Treat the URL as opaque: do not parse it, store it or build it yourself.
After confirm
Confirm returns the stored asset, including its id and delivery url. Store the id against your own record; it is what you need to build variants, sign URLs or delete the file later. Variants are then just URLs, so a profile page can render dreep.url(asset, { width: 256, height: 256, fit: "cover", format: "webp" }) without any further API call.
Trade-offs of direct uploads
- Three round trips instead of one. For small files from a server, a single multipart
POST /api/v1/uploadis simpler. - Abandoned uploads leave
pendingrecords. They do not appear in listings, but your own pending-upload table should expire old rows. - You cannot inspect the bytes before they are stored, since they never pass through your server. Inspect after confirm if you need to, and delete what fails.
More on the security model on the secure file uploads page, and the full request reference in the uploading guide.
Try the examples against your own project
The Free plan includes 1 GB of storage, 100 image and 20 video transformations a month, which is enough to run every snippet in these posts.