The safest browser workflow is server-signing followed by a direct upload. Keep cloud credentials on your server, validate the screenshot there, create a short-lived presigned URL for one object key, and let the browser send the image with an HTTP PUT. Amazon S3 and Cloudflare R2 use this pattern. Backblaze B2’s native API instead gives you a temporary upload URL with b2_get_upload_url, followed by a raw-byte b2_upload_file request.
This guide gives complete server, browser, cURL, Python, and Node.js examples, then compares limits, retries, CORS, visibility, and failure modes.
The upload design that works for all three providers
- Receive metadata, not credentials, from the browser. Send the selected file’s MIME type and size to your application server.
- Validate before signing. Allow only the image types and maximum size your application needs. Do not trust a user-supplied filename.
- Create a collision-resistant key. A pattern such as
screenshots/{userId}/{uuid}.pngprevents one upload from silently replacing another. - Sign one operation. Bind the bucket, key, HTTP method, and expected
Content-Typeto a short-lived URL. - Upload directly. The browser uses
PUTwith exactly the headers that were signed. - Verify completion. Have your server issue a
HEADrequest or use the provider SDK before marking the screenshot complete in your database.
A presigned URL carries the permissions of the identity that created it. Anyone who obtains it can use that one authorization until it expires, so return it only over HTTPS, keep its lifetime short, and never put permanent cloud keys in JavaScript.
Amazon S3: presigned PUT upload
Server-side signer in Node.js
The signer needs permission to perform the underlying s3:PutObject operation. This example accepts only PNG uploads and gives the browser a five-minute URL.
#1 Best Overall
- Easily store and access 2TB to content on the go with the Seagate Portable Drive, a USB external hard drive
- Designed to work with Windows or Mac computers, this external hard drive makes backup a snap just drag and drop
- To get set up, connect the portable hard drive to a computer for automatic recognition no software required
- This USB drive provides plug and play simplicity with the included 18 inch USB 3.0 cable
- The available storage capacity may vary.
import crypto from 'node:crypto';
import express from 'express';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const app = express();
app.use(express.json());
const s3 = new S3Client({ region: process.env.AWS_REGION });
const bucket = process.env.S3_BUCKET;
app.post('/api/screenshot-upload', async (req, res) => {
const { contentType, size } = req.body;
if (contentType !== 'image/png' || !Number.isInteger(size) || size < 1 || size > 10 * 1024 * 1024) {
return res.status(400).json({ error: 'PNG required and file must be 10 MB or smaller' });
}
const userId = req.user.id; // Obtain this from your authenticated session
const key = `screenshots/${userId}/${crypto.randomUUID()}.png`;
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ url, key, contentType });
});
app.listen(3000);
The URL authorizes one key. Uploading another image to an existing S3 key replaces that object, so never derive keys solely from an original filename.
Browser upload
async function uploadScreenshot(file) {
const ticket = await fetch('/api/screenshot-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentType: file.type, size: file.size })
}).then(r => r.json());
const response = await fetch(ticket.url, {
method: 'PUT',
headers: { 'Content-Type': ticket.contentType },
body: file
});
if (!response.ok) throw new Error(`S3 upload failed: ${response.status}`);
await fetch('/api/screenshot-upload/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ticket.key })
});
}
For a browser origin, configure the bucket CORS rule for the exact origins you operate. Allow PUT and the signed request headers; expose ETag only if your client needs to read it.
cURL and Python clients
curl -X PUT -H 'Content-Type: image/png' --upload-file shot.png "$PRESIGNED_URL"
import boto3
import uuid
s3 = boto3.client('s3', region_name='us-east-1')
key = f'screenshots/user-123/{uuid.uuid4()}.png'
url = s3.generate_presigned_url(
'put_object',
Params={'Bucket': 'my-screenshot-bucket', 'Key': key, 'ContentType': 'image/png'},
ExpiresIn=300,
HttpMethod='PUT'
)
print(url)
# The client must PUT the bytes with Content-Type: image/png.
Signature Version 4 presigned requests can also bind checksum headers. If you use one, calculate the checksum before signing and send the identical header with the upload.
Cloudflare R2: S3-compatible signing with important differences
Configure the R2 client
R2 uses the AWS SDK protocol, but the endpoint is your account’s R2 endpoint, the region is auto, and authentication uses an R2 API token. A one-hour expiry appears in Cloudflare’s example; choose a shorter lifetime when practical.
Free tools Windows power users keep installed
One-click scans. No signup required.
import crypto from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY
}
});
const key = `screenshots/user-123/${crypto.randomUUID()}.png`;
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET,
Key: key,
ContentType: 'image/png'
});
const url = await getSignedUrl(r2, command, { expiresIn: 600 });
console.log(JSON.stringify({ url, key }));
The browser code is the same as S3, but the Content-Type must exactly match the value signed by R2. R2 presigned URLs support GET, HEAD, PUT, and DELETE. They are bearer tokens and may expire from one second to seven days. R2 does not support HTML-form POST uploads for presigned URLs, so use the signed PUT flow.
Rank #2
- Easily store and access 1TB to content on the go with the Seagate Portable Drive, a USB external hard drive.Specific uses: Personal
- Designed to work with Windows or Mac computers, this external hard drive makes backup a snap just drag and drop. Reformatting may be required for Mac
- To get set up, connect the portable hard drive to a computer for automatic recognition no software required
- This USB drive provides plug and play simplicity with the included 18 inch USB 3.0 cable
- The available storage capacity may vary.
Size and multipart choices
Cloudflare documents a 5 GiB limit for one upload and multipart objects up to 5 TiB, with as many as 10,000 parts. Multipart parts must be 5 MiB to 5 GiB. Multipart uploads can resume and run in parallel; a failed single PUT has to start over. Abort incomplete multipart uploads so abandoned parts do not remain indefinitely.
R2 browser checklist
- Add the precise application origins to the bucket CORS configuration.
- Allow
PUTand the headers your signature includes, especiallyContent-Type. - Expose
ETagonly when the browser must read it. - Never expose the R2 API token; only return the temporary URL and object key.
Backblaze B2: use the Native API upload URL
B2’s native flow is not the same as copying an S3 presigned example. Your server first calls b2_get_upload_url for a bucket, then sends the screenshot bytes as the raw body to b2_upload_file. Include Content-Length; chunked transfer encoding is unsupported.
Python example
import hashlib
import os
import requests
# B2_AUTH_URL and credentials are supplied by your deployment environment.
auth = requests.get(
os.environ['B2_AUTH_URL'],
auth=(os.environ['B2_KEY_ID'], os.environ['B2_APPLICATION_KEY']),
timeout=30
)
auth.raise_for_status()
a = auth.json()
upload_info = requests.post(
a['apiUrl'] + '/b2api/v3/b2_get_upload_url',
headers={'Authorization': a['authorizationToken']},
json={'bucketId': os.environ['B2_BUCKET_ID']},
timeout=30
)
upload_info.raise_for_status()
u = upload_info.json()
data = open('shot.png', 'rb').read()
file_name = 'screenshots/user-123/shot.png'
headers = {
'Authorization': u['authorizationToken'],
'X-Bz-File-Name': file_name,
'Content-Type': 'image/png',
'Content-Length': str(len(data)),
'X-Bz-Content-Sha1': hashlib.sha1(data).hexdigest()
}
result = requests.post(u['uploadUrl'], headers=headers, data=data, timeout=90)
result.raise_for_status()
print(result.json()['fileId'])
In production, your server should perform both API calls and return a short-lived application-specific upload ticket rather than B2 credentials. B2 returns a unique file ID. Server-side encryption defaults to SSE-B2 when enabled.
Manual console upload
For an occasional file, the Backblaze web console lets you drag an image into a bucket. Its documented single-file limit is 500 MB. A public bucket is publicly readable, never publicly writable; uploading still requires credentials. B2 also provides S3-style URLs for public objects. Do not put PHI or PII in bucket names, object names, folder names, or metadata.
S3, R2, and B2 compared for screenshot uploads
| Question | Amazon S3 | Cloudflare R2 | Backblaze B2 |
|---|---|---|---|
| Browser path | Presigned PUT |
Presigned PUT; HTML-form POST presigned uploads are not supported |
Native upload URL, then raw-body upload |
| Credential exposure | Only temporary URL reaches the browser | Only temporary URL reaches the browser; treat it as a bearer token | Keep application credentials and upload authorization on the server |
| Content-Type control | Bind it when signing; send the same value | Bind it when signing; exact match required | Send it in the upload headers |
| Single-object limit | Not stated in the supplied provider material | 5 GiB single upload (Cloudflare, 2026) | Not stated for the API in the supplied material; console limit is 500 MB per file |
| Multipart | Use the provider’s multipart API for large or unreliable files | Up to 5 TiB, 5 MiB–5 GiB parts, 10,000 parts (Cloudflare, 2026) | Use the B2 multipart API when a single request is unsuitable |
| Retry behavior | Retry a failed small PUT; multipart for resumability | Single PUT restarts; multipart resumes and parallelizes | Retry with a fresh upload authorization when required |
| Pricing and egress | Check current regional pricing | Check current R2 pricing and egress terms | Check current B2 pricing and egress terms |
Security and operational checklist
- Authenticate the user before issuing a URL and derive the key from server-side identity.
- Validate the declared MIME type, byte length, and (where necessary) the file signature or decoded image.
- Use a random ID in every key. A presigned upload to an existing key replaces that object.
- Sign only the required headers and reject a completion callback for an unexpected key or size.
- Use private buckets unless public delivery is an explicit requirement. Store display metadata, ownership, and moderation state in your database rather than trusting object names.
- Set CORS to exact origins, not a blanket wildcard, when the bucket contains user data.
- For large files, use multipart uploads and a cleanup policy for incomplete uploads.
- Verify with
HEADor the provider SDK before reporting success to the user.
Troubleshooting common failures
HTTP 403 or “AccessDenied”
The signing identity may lack permission for the bucket/key, the URL may be expired, or a bucket policy may deny the request. Re-sign with the intended key and verify IAM or token permissions.
Rank #3
- High capacity in a small enclosure – The small, lightweight design offers up to 6TB* capacity, making WD Elements portable hard drives the ideal companion for consumers on the go.
- Plug-and-play expandability
- Vast capacities up to 6TB[1] to store your photos, videos, music, important documents and more
- SuperSpeed USB 3.2 Gen 1 (5Gbps)
“SignatureDoesNotMatch”
The method, region, endpoint, key, query string, or a signed header differs from what the client sent. For R2, confirm region: 'auto' and the account endpoint. Do not add or alter a signed Content-Type.
Browser reports a CORS error
CORS is evaluated by the storage service, not your application server. Add the exact origin, allow PUT and the requested headers, then retry from a fresh browser request.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Upload succeeds but the image is unusable
Check that the bytes are the original file, the content type matches the format, and no proxy converted the request to text. A server-side HEAD check should confirm length and metadata.
R2 form upload fails
Presigned HTML-form POST uploads are not supported by R2. Generate a presigned URL and use an HTTP PUT.
B2 rejects the request for transfer encoding
Send a concrete Content-Length. B2’s b2_upload_file and b2_upload_part calls do not accept chunked transfer encoding.
Rank #4
- Plug-and-play expandability
- SuperSpeed USB 3.2 Gen 1 (5Gbps)
Large or unreliable uploads restart
That is expected for a single PUT. Switch to multipart, retain the upload ID and completed part list, retry failed parts, and abort abandoned uploads.
The same screenshot keeps changing
Two clients are writing the same object key. Include the authenticated user ID and a UUID in every key, and treat the original filename as display metadata only.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Performance, reliability, and cost decisions
Direct browser uploads keep image bytes off your application server, reducing bandwidth and request time there. A short presigned lifetime limits misuse but must exceed the user’s expected upload duration; renew the ticket rather than extending it to hours. Retry small uploads with exponential backoff. For large files or mobile connections, multipart is the reliability choice because individual parts can be retried or uploaded in parallel.
Storage, request, transfer, and egress charges vary by provider, region, retrieval pattern, and date. The supplied provider material does not establish current prices, so compare each provider’s current pricing page for your region before choosing on cost. Measure the bytes your application actually serves, not merely the screenshot count.
Or skip the browser setup
If you still need the screenshot itself, ScreenshotNeo is an alternative to try first: it returns a clean image or PDF from one request, removes consent banners, newsletter popups, and chat widgets before capture, and bills only clean shots.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minutecURL (full options are in the ScreenshotNeo documentation):
Best Value
- 【Upgraded version】 - The mirror logo strip is combined with the striped non-slip design. The rounded corners of the shell are more suitable for holding. The strips play a heat dissipation function to ensure a stable and fast transmission process.
- 【Ultra-thin and quiet】 - The motherboard adopts JMicron 578 noise-free solution, giving you a quiet working environment. Lightweight and portable size designed to fit in your pocket for easy portability.
- 【Ultra-Fast Data Transfers】 - Pairing this external hard drive with JMicron 578 solution USB 3.0 and USB 2.0 interfaces enables blazing-fast data transfer. It boasts theoretical read speeds of up to 125MB/s and write speeds of up to 103MB/s.
- 【Plug and Play】 - With no software to install, just plug it in and the drive is ready to use.The hard disk chip is wrapped with an aluminum anti-interference layer to increase heat dissipation and protect data.
- 【What You Get】 - 1 x Portable Hard Drive, 1 x USB 3.0 Cable, 1 x User Manual, Gift-type shell packaging ,Three-year manufacturer's warranty and free technical support services.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. Its MCP server lets Claude, Cursor, or another MCP client call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. After receiving the image, upload it to S3, R2, or B2 using the methods above. Create a free ScreenshotNeo account.
Frequently Asked Questions
Can I reuse one presigned URL for several screenshots?
A URL is authorized for its specific operation and object key. Issue a new URL per object so retries and ownership checks remain unambiguous.
Should screenshot object names contain a user’s email address?
No. Use an internal user ID and a random identifier in the key, and keep human-readable labels in your application database.
Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minutePC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11When should I make a screenshot bucket public?
Only when anyone who obtains the object URL is allowed to read it. Keep user uploads private and deliver them through authenticated application routes when access must be controlled.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




