Create pre-signed URLs for R2 in Worker?

For Workers & Pages, what is the name of the domain?

What is the issue or error you’re encountering

I want to create pre-signed URLs for an R2 Object in Worker code (probably Rust)

What steps have you taken to resolve the issue?

The Worker R2 API has a very limited scope. I’d like to create a pre-signed URL and return it to clients (desktop and mobile) so that they can use the S3 SDK to upload objects directly, instead of going through a Worker that would just be a proxy to R2 anyway.

Hi, a bit late, but if you do not have an answer yet or to help fellow devs…

I tried using AWS SDK to generate a pre-signed URL, it worked locally, but when running inside a worker we get:

Error: [unenv] fs.readFile is not implemented yet!

After playing around with it a bit I implemented the custom presigned URL generation from scratch using the AWS Signature Version 4 specification. Here you have it:

/**
 * R2 Utilities for Cloudflare Workers
 * Custom implementation of AWS Signature Version 4 for R2 presigned URLs
 */

interface R2Credentials {
  R2_ACCESS_KEY_ID?: string;
  R2_SECRET_ACCESS_KEY?: string;
  R2_ACCOUNT_ID?: string;
  R2_BUCKET?: string;
}

/**
 * Generate a presigned URL for R2 uploads using AWS Signature Version 4
 * Compatible with Cloudflare Workers (no Node.js dependencies)
 */
export async function generateR2PresignedUrl(
  key: string,
  contentType: string,
  expiresIn: number,
  credentials: R2Credentials
): Promise<string> {
  // Validate inputs
  if (
    !credentials.R2_ACCESS_KEY_ID ||
    !credentials.R2_SECRET_ACCESS_KEY ||
    !credentials.R2_ACCOUNT_ID
  ) {
    throw new Error("Missing required R2 credentials");
  }

  if (!key || !contentType || expiresIn <= 0) {
    throw new Error("Invalid parameters for presigned URL generation");
  }

  const date = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
  const dateStamp = date.slice(0, 8);

  // Create the canonical request with bucket name in path
  const bucketName = credentials.R2_BUCKET || "development";
  const canonicalUri = `/${bucketName}/${key}`;

  // Build query parameters for presigned URL
  const algorithm = "AWS4-HMAC-SHA256";
  const credentialScope = `${dateStamp}/auto/s3/aws4_request`;
  const credential = `${credentials.R2_ACCESS_KEY_ID}/${credentialScope}`;

  const queryParams = {
    "X-Amz-Algorithm": algorithm,
    "X-Amz-Credential": credential,
    "X-Amz-Date": date,
    "X-Amz-Expires": expiresIn.toString(),
    "X-Amz-SignedHeaders": "content-type;host;x-amz-date",
  };

  // Sort query parameters alphabetically
  const sortedQueryParams = Object.keys(queryParams)
    .sort()
    .map(
      (key) =>
        `${key}=${encodeURIComponent(
          queryParams[key as keyof typeof queryParams]
        )}`
    )
    .join("&");

  const canonicalQueryString = sortedQueryParams;

  // Create canonical headers (must be sorted alphabetically)
  const canonicalHeaders = `content-type:${contentType}\nhost:${credentials.R2_ACCOUNT_ID}.r2.cloudflarestorage.com\nx-amz-date:${date}\n`;
  const signedHeaders = "content-type;host;x-amz-date";

  // Create the payload hash (empty for PUT requests)
  const payloadHash = "UNSIGNED-PAYLOAD";

  const canonicalRequest = [
    "PUT",
    canonicalUri,
    canonicalQueryString,
    canonicalHeaders,
    signedHeaders,
    payloadHash,
  ].join("\n");

  // Create the string to sign
  const stringToSign = [
    algorithm,
    date,
    credentialScope,
    await sha256(canonicalRequest),
  ].join("\n");

  // Calculate the signature
  const signature = await getSignatureKey(
    credentials.R2_SECRET_ACCESS_KEY!,
    dateStamp,
    "auto",
    "s3",
    stringToSign
  );

  // Build the presigned URL with the signature and bucket name
  const url = `https://${credentials.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${bucketName}/${key}?${canonicalQueryString}&X-Amz-Signature=${signature}`;

  // Validate the generated URL
  try {
    new URL(url);
  } catch (error) {
    throw new Error(`Generated invalid URL: ${error}`);
  }

  return url;
}

/**
 * Calculate SHA256 hash using Web Crypto API
 */
async function sha256(message: string): Promise<string> {
  try {
    const msgBuffer = new TextEncoder().encode(message);
    const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
  } catch (error) {
    throw new Error(`SHA256 calculation failed: ${error}`);
  }
}

/**
 * Calculate HMAC-SHA256 using Web Crypto API
 */
async function hmacSha256(
  key: ArrayBuffer,
  message: string
): Promise<ArrayBuffer> {
  try {
    const cryptoKey = await crypto.subtle.importKey(
      "raw",
      key,
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["sign"]
    );
    const messageBuffer = new TextEncoder().encode(message);
    return await crypto.subtle.sign("HMAC", cryptoKey, messageBuffer);
  } catch (error) {
    throw new Error(`HMAC-SHA256 calculation failed: ${error}`);
  }
}

/**
 * Generate AWS Signature Version 4 signing key
 */
async function getSignatureKey(
  key: string,
  dateStamp: string,
  regionName: string,
  serviceName: string,
  stringToSign: string
): Promise<string> {
  try {
    const kDate = await hmacSha256(
      new TextEncoder().encode("AWS4" + key).buffer as ArrayBuffer,
      dateStamp
    );
    const kRegion = await hmacSha256(kDate, regionName);
    const kService = await hmacSha256(kRegion, serviceName);
    const kSigning = await hmacSha256(kService, "aws4_request");
    const signature = await hmacSha256(kSigning, stringToSign);

    const signatureArray = new Uint8Array(signature);
    return Array.from(signatureArray)
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");
  } catch (error) {
    throw new Error(`Signature key generation failed: ${error}`);
  }
}

/**
 * Test function to validate presigned URL generation
 * This can be used for debugging purposes
 */
export async function testPresignedUrlGeneration(
  credentials: R2Credentials
): Promise<{
  success: boolean;
  url?: string;
  error?: string;
  debug?: {
    canonicalRequest: string;
    stringToSign: string;
    signature: string;
  };
}> {
  try {
    const testKey = "test/upload.txt";
    const testContentType = "text/plain";
    const testExpiresIn = 3600;

    const url = await generateR2PresignedUrl(
      testKey,
      testContentType,
      testExpiresIn,
      credentials
    );

    // Validate URL format
    const urlObj = new URL(url);
    if (!urlObj.searchParams.has("X-Amz-Signature")) {
      throw new Error("Generated URL missing signature");
    }

    return {
      success: true,
      url,
      debug: {
        canonicalRequest: "See generateR2PresignedUrl for details",
        stringToSign: "See generateR2PresignedUrl for details",
        signature: urlObj.searchParams.get("X-Amz-Signature") || "unknown",
      },
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : String(error),
    };
  }
}

Cheers!

1 Like