Signed URLs for private images and video
How HMAC-signed, expiring URLs protect user media, how to choose expiry times, and how to generate them in JavaScript and Go with Dreep.
· 6 min read
Invoices, ID documents, paid course video, photos in a private album: plenty of media must not be fetchable by anyone who guesses or shares a URL. But the browser still has to load it with a plain <img> or <video> tag, which cannot send your app's session cookie to a third-party host or attach an Authorization header. Signed URLs solve this. Your server, after its own permission check, hands the browser a URL that works for a few minutes and then stops.
This post explains how the signing works, how to pick folder modes and expiry times, and what signing does not protect you from.
Three folder modes
Dreep controls access per folder, and every asset inherits its folder's mode. Folders created from a path inherit from their parent, so a new subfolder under a signed folder is never silently public.
| Mode | Who can fetch | Use it for |
|---|---|---|
| Public | Anyone with the URL | Product photos, avatars, marketing media |
| Signed | Anyone holding an unexpired signed URL | User documents, paid content, private albums |
| Private | Project members in the Dreep dashboard only | Internal files never shown to end users |
Private is stricter than it sounds: there is no API for serving private media to your users at all. If your users need to see a file, the folder should be signed.
How the signature works
Browser
Asks your app for a link
Your server
Checks permission, signs locally
Signed URL
...?exp=1774118400&sig=a3f9...
Dreep
Recomputes HMAC, checks exp
A signed URL is the normal delivery URL plus two parameters. exp is a Unix timestamp in seconds. sig is a hex HMAC-SHA256, keyed with your project's URL signing secret, over the asset id and that timestamp, and over the transform parameters when the link carries any. Dreep recomputes the HMAC on each request. If exp has passed, or anything signed was altered, the request is refused with 401.
Because the HMAC covers the expiry, a user cannot extend a link by editing exp. Because it covers the asset id, a link for one invoice cannot be turned into a link for another. Signing happens entirely on your server with the secret, so it adds no latency and no API call to your request.
Setting up a signed folder
import { Dreep } from "dreep";
const dreep = new Dreep({
apiKey: process.env.DREEP_API_KEY,
signingSecret: process.env.DREEP_SIGNING_SECRET, // API Keys page in the dashboard
});
// Once: a signed folder. Subfolders created under it inherit "signed".
await dreep.createFolder({ path: "invoices", accessControlType: "signed", defaultExpirySeconds: 3600 });
// Uploads into it are not fetchable with a bare URL
const asset = await dreep.upload({ file, folder: "invoices/2026/acme" });defaultExpirySeconds is a convenience for links you generate by hand in the dashboard. Links from your code always carry the expiry you pass.
Generating links in JavaScript
// In a route that has already checked the user may see this asset
app.get("/invoices/:id/link", requireUser, async (req, res) => {
const invoice = await db.invoices.find({ id: req.params.id, accountId: req.user.accountId });
if (!invoice) return res.sendStatus(404);
const url = dreep.signedUrl(
{ id: invoice.assetId, url: invoice.assetUrl },
{ expiresIn: 300 }, // five minutes
);
res.json({ url });
});
// A signed, transformed variant: the transform is part of the signature
const preview = dreep.signedUrl(asset, {
expiresIn: 900,
transform: { width: 828, format: "webp" },
});When a signed link carries a transform, Dreep snaps width, height, quality and dpr onto its fixed breakpoints before verifying, and the SDK applies the same snapping before signing. That is why you should let the SDK sign transformed links rather than hand-rolling them: a hand-signed width=803 is verified as 828 and fails.
Generating links in Go
client, err := dreep.New(
os.Getenv("DREEP_API_KEY"),
dreep.WithSigningSecret(os.Getenv("DREEP_SIGNING_SECRET")),
)
if err != nil {
log.Fatal(err)
}
// After your own authorisation check
url, err := client.SignedURL(invoice.AssetID, 5*time.Minute, nil)
if err != nil {
return err // dreep.ErrNoSigningSecret if the secret is missing
}Pass nil as the transform in Go. At the time of writing, the Go SDK signs the asset id and expiry only, which is what an untransformed link needs. If a Go service needs signed, transformed variants, generate those links with the JavaScript SDK for now.
Signing by hand
To see there is no magic, here is the untransformed case with nothing but node:crypto. Use the SDKs in production; this is for understanding, or a stack without an SDK.
import { createHmac } from "node:crypto";
// For understanding, or for a stack without an SDK.
// Only valid for links WITHOUT transform parameters.
const signUntransformed = ({ assetId, expiresIn, signingSecret }) => {
const exp = Math.floor(Date.now() / 1000) + expiresIn;
const sig = createHmac("sha256", signingSecret).update(assetId + ":" + exp).digest("hex");
return "https://cdn.dreep.cloud/api/v1/fetch/" + assetId + "?exp=" + exp + "&sig=" + sig;
};Uploading into a signed folder
Browser uploads work the same way as for public media. Your backend presigns with a destination inside the signed folder, the browser PUTs, and your backend confirms. The asset is never publicly fetchable, not even for a moment, because access is decided by the folder at request time. After confirm, return a freshly signed URL to the client so it can show the upload straight away.
Keep the signing secret next to the API key in your server's secret store. It is a separate credential: the API key lets you manage the project, while the signing secret only lets you mint links. If a frontend team needs to generate links, give them an endpoint on your backend, never the secret.
Choosing an expiry
A signed URL is a bearer token: anyone who holds it can fetch the asset until it expires, with no login. The expiry is the only thing limiting the damage if a link is shared, logged or leaked through a Referer header. Pick the shortest window the use allows:
- Download buttons: a few minutes. Generate the link when the user clicks, not when the page renders.
- Images on a page: long enough for the page session, typically 15 to 60 minutes. Regenerate on the next page load.
- Video: longer than the video plus some pause time, because the player keeps fetching segments while it plays. A link that expires mid-lesson stops playback.
- Email attachments and shared links: hours or days at most, and only if the content can tolerate being forwarded.
What signing does not do
- It does not decide who should see what. The signature proves your server issued the link; your authorisation check before signing is the actual access control.
- Individual links cannot be revoked. Rotating the signing secret in the dashboard invalidates every outstanding link at once, and deleting an asset makes its links stop working.
- It does not stop a user who can view a file from saving it. If that matters, signed URLs are one layer, not DRM. Dreep does not offer DRM.
Caching and cost trade-offs
Signed and private media is sent with Cache-Control: private, no-store, so neither browsers nor shared caches keep a copy. That is deliberate: a copy cached while a folder was public must not keep leaking after it is locked down. The trade-off is that every view of a signed URL is a fresh request.
Each transformed request counts against your monthly transformation allowance, and with no-store repeat views of a signed, transformed image are not absorbed by a cache. For a grid of hundreds of signed thumbnails viewed many times a day, that adds up. Two ways to keep it in check: normalise images at upload with an upload transform so the stored file is already the size you show, and ask whether every thumbnail needs signing at all, since a blurred preview might sit in a public folder while the full image stays signed.
The full protocol is in the signed URLs docs and folder modes in folders and access control. For upload-side security, see secure file uploads.
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.