Introduction
A browser upload page can fail even when a presigned URL works in curl. The browser also checks whether the storage service allows the page's origin. You will configure that independent CORS boundary for a supplied synthetic upload page while keeping the bucket private.
Complete Authorize Temporary File Access first. This fresh VM provides pinned Node.js 22.22.0, Wrangler 4.131.1 and AWS SDK 3.888.0, plus a complete unrelated upload-page shell. You create a new private bucket, a short-lived object credential and a disposable Worker page. R2 must already be active; review R2 pricing and CORS behavior. No purchased domain is needed. Use only the supplied synthetic text; clean up all resources and revoke the lab tokens.
Connect the application bucket
In this step, you authorize this VM and create an independent private bucket for the application. Device authorization confirms your learning account. R2 bucket management uses a separate API token restricted to that account.
Start Bash for the command syntax used below, then move to the prepared project and check its tools. Keep this same terminal open so your resource-name variables remain available:
bash
cd /home/labex/project/r2-lab
export PATH="$PWD/.tools/node-v22.22.0-linux-x64/bin:$PATH"
node --version
npx wrangler --version
Authorize the displayed device code in your own browser. Confirm the learning account and the requested account and user read scopes before granting consent:
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write
npx wrangler whoami --json
Require loggedIn: true. Read the account name even if only one is listed. Replace YOUR_ACCOUNT_ID below with that account's actual 32-character ID. openssl rand -hex 6 generates twelve random hexadecimal characters so this lab cannot collide with a previous run. The here-document writes a standard configuration file; the shell substitutes your variables into it.
ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r05-$RUN_ID"
BUCKET="$NAME-docs"
cat > wrangler.jsonc <<JSON
{"name":"$NAME","account_id":"$ACCOUNT_ID","main":"src/index.js","workers_dev":true,"compatibility_date":"2026-07-30","r2_buckets":[{"binding":"DOCUMENTS","bucket_name":"$BUCKET"}]}
JSON
For bucket management, open your Cloudflare profile's API Tokens page and create a custom token named after this lab. Grant Account → Workers R2 Storage → Edit, and restrict Account Resources to the learning account whose ID you saved. Set a short expiry. Do not include other accounts or unrelated permissions. This account-level permission creates/deletes buckets; the object-only token in the next step cannot do that.
Copy the token once into this hidden VM prompt. umask 077 restricts the file to your user; read -s hides input. The file uses Wrangler's standard token variable and is excluded from Git.
umask 077
read -r -s -p 'R2 management API token: ' R2_MANAGEMENT_TOKEN; printf '\n'
printf 'CLOUDFLARE_API_TOKEN=%s\n' "$R2_MANAGEMENT_TOKEN" > .env.management
unset R2_MANAGEMENT_TOKEN
Use --env-file=.env.management only for R2 management commands; ordinary whoami continues to check the VM's device authorization.
Put --env-file at the end of each Wrangler command so its list of file arguments does not consume the command name. After creating each bucket, if Wrangler asks whether to add a binding to your configuration, type n and press Enter. The configuration already contains the intended binding.
npx wrangler r2 bucket create "$BUCKET" --env-file=.env.management
List your buckets and find the exact generated name. Other buckets belong to other work; leave them alone.
npx wrangler r2 bucket list --env-file=.env.management
In Dashboard, open Storage & databases → R2 → Overview, select this exact bucket, and inspect its empty object list. In its settings, leave the public development URL and custom domains disabled. A bucket name in Dashboard confirms identity; later download checks prove the stored bytes.
Worker script permission supports deployment. KV permission supports Wrangler’s deletion bookkeeping; this lab creates no KV namespace. The R2 management token remains a separate account-scoped credential.
Prepare the upload page and signing access
In this step, you deploy the supplied page and give the terminal a credential scoped to this bucket. The page contains only a fixed synthetic payload and an empty URL field; it never receives the long-lived signing secret.
The S3-compatible API gives standard storage SDKs access to R2. It uses a separate access-key pair rather than Wrangler's device token. In R2 Overview, use Account Details → API Tokens → Manage, then create a User API token named after this lab's generated resource name. Choose Object Read & Write, restrict it to this exact new bucket, and select a short expiry if the form offers one. Do not choose all buckets or Admin access. Keep this token page available until you have stored the one-time secret.
Use the following Bash prompts in the VM. read -s hides input; umask 077 makes the credential file readable only by your user. These names are the standard AWS SDK environment variables. Paste the Access Key ID and Secret Access Key into their respective prompts, then press Enter. Do not paste the general API token value.
umask 077
read -r -s -p 'Access Key ID: ' AWS_ACCESS_KEY_ID; printf '\n'
read -r -s -p 'Secret Access Key: ' AWS_SECRET_ACCESS_KEY; printf '\n'
printf 'AWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\n' "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" > .env.s3
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
Write a reusable standard SDK client. The SDK requires a region string; R2 uses auto. Reading the existing configuration keeps CLI and SDK operations aimed at the same account and bucket.
cat > storage.mjs <<'JS'
import { S3Client } from "@aws-sdk/client-s3";
import { readFileSync } from "node:fs";
const config = JSON.parse(readFileSync("wrangler.jsonc", "utf8"));
export const Bucket = config.r2_buckets[0].bucket_name;
export const s3 = new S3Client({
region: "auto",
requestChecksumCalculation: "WHEN_REQUIRED",
endpoint: `https://${config.account_id}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
JS
requestChecksumCalculation: "WHEN_REQUIRED" avoids signing an empty-body checksum before the browser supplies the payload. The URL still signs the object and operation; the signing call below explicitly includes Content-Type. The final check compares the actual stored bytes.
Publish the supplied page:
npx wrangler deploy
Copy the HTTPS deployment URL into ORIGIN with no path or trailing slash. An origin consists of scheme, hostname and port; https://example.com differs from http://example.com. Save it for the later independent check:
ORIGIN=https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev
printf "%s\n" "$ORIGIN" > origin.txt
Open that URL in your browser. Require the Synthetic export upload page with an empty temporary URL field. Do not upload yet; the bucket does not yet allow this browser origin.
Allow one browser origin
In this step, you configure Cross-Origin Resource Sharing (CORS). Before sending a cross-origin PUT, a browser asks the bucket whether this page's origin, method and headers are allowed. That OPTIONS request is a preflight. CORS controls browser access; it does not replace the signed operation's storage authorization.
Write a policy using the actual page origin. The terminal substitutes $ORIGIN into the JSON. Wrangler uses Cloudflare's lowercase rules/allowed format rather than AWS's CORSRules format:
cat > cors.json <<JSON
{"rules":[{"allowed":{"origins":["$ORIGIN"],"methods":["PUT"],"headers":["content-type"]},"exposeHeaders":["ETag"],"maxAgeSeconds":60}]}
JSON
Only PUT and Content-Type are needed by this supplied page. exposeHeaders allows JavaScript to read the returned ETag, and the short maxAgeSeconds reduces preflight caching during testing.
npx wrangler r2 bucket cors set "$BUCKET" --file cors.json --env-file=.env.management
npx wrangler r2 bucket cors list "$BUCKET" --env-file=.env.management
Confirm overwriting the policy only for this newly created bucket. The resulting list must show your exact origin. In the bucket's Dashboard settings, inspect the CORS policy read-only and keep public access disabled.

Example: the policy allows this page origin, PUT and content-type. Your generated hostname will differ. The CLI output also checks exposed ETag and the cache duration.
Upload through the browser
In this step, you combine the signed PUT permission with the browser origin policy. The previously taught signer creates a URL for exactly uploads/browser.txt; the page sends the same Content-Type used when signing.
cat > sign-upload.mjs <<'JS'
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { writeFileSync } from "node:fs";
import { s3, Bucket } from "./storage.mjs";
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket, Key: "uploads/browser.txt", ContentType: "text/plain"
}), { expiresIn: 300, signableHeaders: new Set(["content-type"]) });
writeFileSync("put-url.txt", url, { mode: 0o600 });
console.log("Prepared a five-minute PUT URL");
JS
node --env-file=.env.s3 sign-upload.mjs
Display this temporary link only long enough to copy it into the supplied page. Do not share it, include it in screenshots, or paste the S3 secret instead:
cat put-url.txt
Paste the link into Temporary PUT URL, then select Upload synthetic text within five minutes. Require a successful 2xx HTTP result and a visible ETag. Clear the URL field after the upload. An expired URL can appear as a generic browser CORS failure because error responses may lack CORS headers; generate a fresh link if needed.
Download the real object through Wrangler and compare the fixed payload:
npx wrangler r2 object get "$BUCKET/uploads/browser.txt" --remote --file browser-download.txt --env-file=.env.management
printf "Synthetic browser export.\n" > expected-browser.txt
cmp expected-browser.txt browser-download.txt
The platform check independently reads bytes and content type. The actual browser interaction remains necessary evidence for this lab; curl alone does not enforce browser CORS.

The real browser returned HTTP 200 and a readable ETag; the temporary URL field has been cleared. The page displays a literal \n, but uploads an actual newline. The independent download check confirms the exact 26-byte payload.
Separate origin policy from signature permission
In this step, you inspect an allowed and disallowed preflight without creating another object. These curl requests ask what the browser is permitted to do; curl itself does not enforce that answer.
ACCOUNT_ID=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).account_id")
OBJECT_URL="https://$ACCOUNT_ID.r2.cloudflarestorage.com/$BUCKET/uploads/browser.txt"
curl -i -X OPTIONS -H "Origin: $ORIGIN" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type" "$OBJECT_URL"
Require Access-Control-Allow-Origin matching the page's exact origin. Now ask from an unrelated origin:
curl -i -X OPTIONS -H "Origin: https://outside.example" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type" "$OBJECT_URL"
The response must not grant that origin (nor *). A denial response's exact status can differ; the missing grant is what prevents browser access. Finally, attempt an unsigned read:
curl -sS -o unsigned.xml -w "%{http_code}\n" "$OBJECT_URL"
Require 400 with XML Code InvalidArgument and Message Authorization, as observed for a fully unsigned request to this R2 S3 endpoint. Inspect both fields below; an arbitrary error or network failure does not prove signature rejection.
python3 - <<'PYXML'
from xml.etree import ElementTree
root = ElementTree.parse("unsigned.xml").getroot()
print("Code:", root.findtext("Code"))
print("Message:", root.findtext("Message"))
PYXML
Enabling CORS did not make the bucket public or remove signature checks. A non-browser client with a valid signature can still use the granted operation regardless of an Origin header; origin strings are not identity credentials.
Remove upload resources and credentials
In this step, you remove the UI Worker, the exact object and the bucket. Confirm resource absence before revoking credentials.
npx wrangler delete
npx wrangler r2 object delete "$BUCKET/uploads/browser.txt" --remote --env-file=.env.management
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management
npx wrangler r2 bucket list --env-file=.env.management
Confirm only this lab's generated names. Refresh the Worker list and bucket list in Dashboard. Run the platform cleanup check while management authorization remains active. The bucket's CORS configuration is removed with the bucket.
Revoke the lab credential and log out
In this step, you close access left by this exercise. On the R2 API Tokens page, revoke only the object token named for this lab. On your profile API Tokens page, revoke the separate R2 management token you created for this lab. Deleting a bucket does not revoke a token, and Wrangler logout does not revoke S3 credentials.
After revocation, remove the local credential file and log this VM out:
rm .env.s3 .env.management put-url.txt
npx wrangler logout
Inspect structured identity. Its nonzero status is expected when logged out:
npx wrangler whoami --json || true
Require loggedIn: false; keep your ordinary Dashboard login. The platform checks local credential removal and Wrangler logout. Both token revocations are manual Dashboard checkpoints in this candidate; it is not inferred from file deletion.
Summary
Configure exact-origin CORS, upload synthetic bytes with a presigned URL, distinguish browser permission from authorization, and clean up.



