hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses File Uploads and Storage with @hectoday/http

The Basics

  • Why File Uploads Are Hard
  • Project Setup

Receiving Files

  • Multipart Form Data
  • Validating Uploads
  • Saving to Disk

Serving Files

  • Serving Static Files
  • Range Requests and Resumable Downloads
  • Access Control on Files

Production Patterns

  • Streaming Uploads
  • Image Processing
  • Upload Progress and Cancellation

Cloud Storage

  • Presigned URLs
  • Moving from Local to Cloud

Putting It All Together

  • File Upload Checklist
  • Capstone: File Sharing API

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?

← Range Requests and Resumable Downloads Streaming Uploads →

© 2026 hectoday. All rights reserved.