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!