Access Control on Files
Not every file is public
A user’s private documents should not be downloadable by anyone who guesses the URL. File access needs the same authentication and authorization checks as any other resource.
Simple auth check
Check the session before serving the file:
route.get("/files/:id/download", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const file = db.prepare("SELECT * FROM files WHERE id = ?").get(c.params.id) as any;
if (!file) return Response.json({ error: "Not found" }, { status: 404 });
// Public files: anyone can download
// Private files: only the owner
if (!file.is_public && file.user_id !== user.id) {
return Response.json({ error: "Not found" }, { status: 404 });
}
// ... stream the file
},
}); Returning 404 (not 403) for unauthorized access prevents revealing that the file exists, same as the IDOR pattern from the web security course.
The problem with auth-gated downloads
Auth-gated file serving has limitations. The browser needs a cookie to download the file. This means the file URL cannot be shared directly (the recipient needs their own session), embedded in an <img> tag from a different origin (no cookies), or used in contexts where cookies are not sent.
Signed URLs
A signed URL is a download link that includes a cryptographic signature and an expiry time. Anyone with the URL can download the file — but only until it expires.
The signature is an HMAC (Hash-based Message Authentication Code): a hash of the file ID and expiry time, keyed with a secret only the server knows. When the URL is used, the server recomputes the HMAC and compares it to the one in the URL. If they match, the URL is authentic. If the file ID or expiry is tampered with, the HMAC will not match.
import { createHmac } from "node:crypto";
const URL_SECRET = process.env.URL_SECRET ?? "change-me-in-production";
export function generateSignedUrl(fileId: string, expiresInSeconds: number = 3600): string {
const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
const payload = `${fileId}:${expiresAt}`;
const signature = createHmac("sha256", URL_SECRET).update(payload).digest("hex");
return `/files/${fileId}/signed?expires=${expiresAt}&sig=${signature}`;
}
export function verifySignedUrl(fileId: string, expires: string, sig: string): boolean {
const expiresAt = parseInt(expires, 10);
if (Date.now() / 1000 > expiresAt) return false; // Expired
const payload = `${fileId}:${expiresAt}`;
const expected = createHmac("sha256", URL_SECRET).update(payload).digest("hex");
return sig === expected;
} The signed download route:
route.get("/files/:id/signed", {
resolve: (c) => {
const url = new URL(c.request.url);
const expires = url.searchParams.get("expires");
const sig = url.searchParams.get("sig");
if (!expires || !sig) {
return Response.json({ error: "Missing signature" }, { status: 400 });
}
if (!verifySignedUrl(c.params.id, expires, sig)) {
return Response.json({ error: "Invalid or expired link" }, { status: 403 });
}
const file = db.prepare("SELECT * FROM files WHERE id = ?").get(c.params.id) as any;
if (!file) return Response.json({ error: "Not found" }, { status: 404 });
// Stream the file — no auth check needed, the signature proves authorization
const filePath = join(UPLOAD_DIR, file.stored_name);
const stat = statSync(filePath);
const nodeStream = createReadStream(filePath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
"content-type": file.mime_type,
"content-length": String(stat.size),
"content-disposition": `inline`,
},
});
},
}); Generating signed URLs
The authenticated API returns signed URLs instead of direct download links:
route.get("/files/:id", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const file = db
.prepare("SELECT * FROM files WHERE id = ? AND user_id = ?")
.get(c.params.id, user.id) as any;
if (!file) return Response.json({ error: "Not found" }, { status: 404 });
const downloadUrl = generateSignedUrl(file.id, 3600); // 1 hour
return Response.json({
id: file.id,
name: file.original_name,
mimeType: file.mime_type,
size: file.size,
downloadUrl,
});
},
}); The client receives a signed URL that works for 1 hour. It can be used in <img> tags, shared with others temporarily, or opened in a new tab — no cookies needed.
Exercises
Exercise 1: Implement the signed URL system. Generate a signed URL. Open it in a browser (or curl without cookies). Verify the file downloads.
Exercise 2: Wait for the URL to expire (set expiry to 10 seconds for testing). Verify the URL no longer works.
Exercise 3: Tamper with the signature (change one character). Verify the URL is rejected.
Exercise 4: Make a file public (is_public = 1). Verify anyone can download it without auth or a signed URL.
Why are signed URLs better than session-based auth for file downloads?