reminder to use presigned urls

published: 06/12/2026

6 min read

Zoom overlay

Table of contents

Open Table of contents

Overview

I forgot pre-signed url exists for uploading assets such as images or files to an object storage service like R2 (cloudflare) or S3 (aws) without across the actual blob content to the our own server.

So here is a reminder to both of us :)

Here’s the link to the code: https://github.com/tanshunyuan/glob-guides/tree/main/presigned-url

Architecture

Use Presigned url

Pre-Requisite

Before looking at the code, we need to a few things on Cloudflare R2 platform.

Creating a bucket

Login to https://pages.cloudflare.com/ , under the “Build” header on the sidebar, click on “Storage & databases > R2 Object Storage > Overview” Use Presigned url-1

You’ll be redirected to this page, click on the ”+ Create bucket” button Use Presigned url-2

After clicking the button, fill up the information shown below, click “Create bucket” and a bucket will be created Use Presigned url-3

After the bucket is created, you’ll be redirected to the page for a bucket Use Presigned url-4

If we head to the home page, we can also see the newly created bucket (test-bucket) Use Presigned url-5

Configure Bucket CORS

We’re uploading from the browser, so we need to update the bucket CORS policy to ensure that it accepts our browser origin. In this case, we’re running the client on http://localhost:3000

Click into the bucket, in this case its test-bucket Use Presigned url-6

Click on “Settings” Use Presigned url-7

On the left sidebar, click on “CORS Policy”, and click the ”+ Add” button Use Presigned url-8

A right sidebar will popup regarding CORS Policy will pop up Use Presigned url-9

We’ll upload the policy with these values, and click “Save”

[
  {
    "AllowedOrigins": ["http://localhost:3000"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"]
  }
]

Find out more about what CORS policy to set here: https://developers.cloudflare.com/r2/buckets/cors/

After saving, the bucket CORS policy is updated Use Presigned url-10

The bucket is now configured to accept a pre-signed url with a PUT request from the browser origin of http://localhost:3000

Credentials

We need these bucket credentials to programatically access it:

On the bottom right corner of the R2 homepage, click the “Manage” button in the red box Use Presigned url-11

You’ll be redirected to this page where, you can CRUD your api keys. Click on “Create User API token” Use Presigned url-12

Choose a name for your token, and the permissions. Once you’re done click “Create User API Token” Use Presigned url-13

The credentials will be generated, copy the values and keep them somewhere you can access them Use Presigned url-14

Code

Cloudflare R2 leverages on AWS S3 SDK for access to their object storage, we’ll need to install these two package to setup the S3 client and generate presigned-url

pnpm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Cloudflare doesn’t support all functions of AWS S3 SDK, read more here: https://developers.cloudflare.com/r2/api/s3/api/. But for this guide it’s enough

Creating the S3 Client

import { S3Client } from "@aws-sdk/client-s3";
import { env } from "../env.js";

const s3 = new S3Client({
  region: "auto", // Required by AWS SDK, not used by R2
  endpoint: env.CLOUDFLARE_R2_URL,
  credentials: {
    accessKeyId: env.CLOUDFLARE_R2_ACCESS_KEY_ID,
    secretAccessKey: env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
  },
});

We can use the S3 SDK alongside with cloudflare credentials, no extra setup is required on our end.

Generate Presigned URL

import express, { type Request, type Response } from "express";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import z from "zod";

const UploadSchema = z.object({
  name: z.string(),
  contentType: z.string(),
});
app.post("/upload", async (req: Request, res: Response) => {
  try {
    const body = UploadSchema.parse(req.body);

    const signedUrl = await getSignedUrl(
      s3,
      new PutObjectCommand({
        Bucket: "ps-dev",
        Key: body.name,
        ContentType: body.contentType,
      }),
      { expiresIn: 3600 },
    );
    return res.status(200).json({ signedUrl });
  } catch (e) {
    if (e instanceof z.ZodError) {
      const pretty = z.prettifyError(e);
      return res.status(400).json(pretty);
    }
    console.log(e);
    return res.status(500).json("Unexpected Error");
  }
});

We only need to define two things: name and contentType of our asset we want to upload through our presigned url.

R2 will generate a presigned url and we need to send it back to the browser for the upload to occur

Fetch items uploaded from Presigned URL

import express, { type Request, type Response } from "express";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import z from "zod";

app.get("/download", async (req: Request, res: Response) => {
  try {
    const name = z.string().min(1).parse(req.query.name);

    const signedUrl = await getSignedUrl(
      s3,
      new GetObjectCommand({
        Bucket: "test-bucket",
        Key: name,
      }),
      { expiresIn: 3600 },
    );

    return res.status(200).json({ signedUrl });
  } catch (e) {
    if (e instanceof z.ZodError) {
      const pretty = z.prettifyError(e);
      return res.status(400).json(pretty);
    }
    console.log(e);
    return res.status(500).json("Unexpected Error");
  }
});

By specificing the Key of our item, we can also use presigned url to retrieve the item we’ve uploaded.

Request and upload to Presigned URL

<!doctype html>
<html>
  <head>
    <title>Object Storage Client</title>
    <style>
      button {
        cursor: pointer;
        padding: 8px 14px;
      }

      button:disabled {
        cursor: not-allowed;
        opacity: 0.7;
      }

      #status {
        min-height: 24px;
        margin-bottom: 16px;
      }

      #uploaded-image {
        display: none;
        max-width: 100%;
        border-radius: 12px;
        box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
      }
    </style>
  </head>
  <body>
    <form id="object-storage-form">
      <input id="file-input" name="content" type="file" accept="image/*" />
      <button id="submit-button" type="submit">Submit</button>
    </form>
    <p id="status" role="status"></p>
    <img id="uploaded-image" alt="Uploaded image" />
  </body>
  <script type="module">
    const BASE_URL = "http://localhost:8086";
    const objectStorageForm = document.getElementById("object-storage-form");
    const fileInput = document.getElementById("file-input");
    const submitButton = document.getElementById("submit-button");
    const statusMessage = document.getElementById("status");
    const uploadedImage = document.getElementById("uploaded-image");

    const setButtonState = (text, isDisabled) => {
      submitButton.textContent = text;
      submitButton.disabled = isDisabled;
    };

    const getDownloadUrl = async (fileName) => {
      const response = await fetch(
        `${BASE_URL}/download?name=${encodeURIComponent(fileName)}`,
      );

      if (!response.ok) {
        throw new Error(`Failed to create download URL: ${response.status}`);
      }

      const { signedUrl } = await response.json();
      return signedUrl;
    };

    const handleFormSubmit = async (event) => {
      event.preventDefault();

      const fileContent = fileInput.files[0];
      if (!fileContent) {
        statusMessage.textContent = "Pick an image first.";
        return;
      }

      setButtonState("Loading...", true);
      statusMessage.textContent = "Uploading image...";

      try {
        const requestBody = {
          name: fileContent.name,
          contentType: fileContent.type,
        };

        const response = await fetch(`${BASE_URL}/upload`, {
          headers: {
            "Content-Type": "application/json",
          },
          method: "POST",
          body: JSON.stringify(requestBody),
        });

        if (!response.ok) {
          throw new Error(`Failed to create signed URL: ${response.status}`);
        }

        const { signedUrl } = await response.json();

        const uploadResponse = await fetch(signedUrl, {
          method: "PUT",
          headers: {
            "Content-Type": fileContent.type,
          },
          body: fileContent,
        });

        if (!uploadResponse.ok) {
          throw new Error(`Failed to upload file: ${uploadResponse.status}`);
        }

        setButtonState("Done", true);
        statusMessage.textContent = "Image uploaded.";

        const downloadUrl = await getDownloadUrl(fileContent.name);
        uploadedImage.src = downloadUrl;
        uploadedImage.style.display = "block";
      } catch (error) {
        console.error(error);
        setButtonState("Submit", false);
        statusMessage.textContent = error.message;
      }
    };

    fileInput.addEventListener("change", () => {
      setButtonState("Submit", false);
      statusMessage.textContent = "";
      uploadedImage.style.display = "none";
      uploadedImage.removeAttribute("src");
    });

    objectStorageForm.addEventListener("submit", handleFormSubmit);
  </script>
</html>

Results

Browser Results

Use Presigned url-15

R2 Storage

Use Presigned url-16

Use Presigned url-17

Conclusion

I chose HTML and express to remind myself I don’t need too fancy of a frontend framework everytime. Sometimes simplicity is the best. I hope i’ll remember I wrote this and refer back to it

If you like these posts, get new ones emailed to you