How to Upload Screenshots to S3, R2, or Backblaze B2
Upload screenshots safely from a browser or server to S3, Cloudflare R2, or Backblaze B2 using presigned and native API workflows.
Use a short-lived presigned PUT URL. Your server validates the screenshot, creates a unique object key, signs one upload operation, and returns the URL to the browser. The browser uploads the bytes directly with HTTP PUT, so cloud credentials never reach the client.
This pattern works for Amazon S3 and Cloudflare R2. Backblaze B2 also supports S3-compatible APIs, but its Native API uses a different two-step flow: request an upload URL with b2_get_upload_url, then send the bytes to b2_upload_file with a Content-Length header.
Architecture: validate, sign, upload, verify
- The browser captures or selects a PNG, JPEG, or WebP screenshot.
- Your application server checks MIME type, dimensions, and size.
- The server creates a collision-resistant key such as
screenshots/user-123/550e8400-e29b-41d4-a716-446655440000.png. - The server signs a PUT URL for that exact key and expected
Content-Type. - The browser uploads with PUT and the signed headers.
- Your server verifies the object with HEAD or the provider SDK before marking the screenshot complete.
Keep application metadata (owner, caption, permissions, capture time) in your database. Do not trust a user-supplied filename or object key.
Browser upload with an S3 presigned URL
Server: Node.js and AWS SDK
import express from 'express';
import crypto from 'node:crypto';
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('/uploads/presign', async (req, res) => {
const { contentType, size } = req.body;
const allowed = new Set(['image/png', 'image/jpeg', 'image/webp']);
if (!allowed.has(contentType)) return res.status(415).json({ error: 'Unsupported image type' });
if (!Number.isInteger(size) || size < 1 || size > 25 * 1024 * 1024) {
return res.status(413).json({ error: 'Invalid image size' });
}
const extension = contentType === 'image/png' ? 'png' : contentType === 'image/webp' ? 'webp' : 'jpg';
const key = `screenshots/${req.user.id}/${crypto.randomUUID()}.${extension}`;
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
// Optional integrity value supplied by a trusted server-side pipeline.
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ uploadUrl, key, contentType, expiresIn: 300 });
});
app.listen(3000);
The IAM principal that signs the URL must be allowed to perform s3:PutObject for the bucket and key prefix. A PUT to an existing key replaces that object, so use unique keys unless replacement is deliberate. See AWS’s presigned upload documentation.
Browser: direct PUT
async function uploadScreenshot(file) {
const presign = await fetch('/uploads/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentType: file.type, size: file.size })
});
if (!presign.ok) throw new Error(await presign.text());
const { uploadUrl, key, contentType } = await presign.json();
const upload = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`);
return key;
}
The Content-Type must match the value included in the signature. Configure bucket CORS for the exact origins that need browser uploads. If the client must read the response ETag, expose ETag in CORS.
Cloudflare R2 presigned uploads
R2 is S3-compatible. Configure the AWS SDK with your R2 endpoint, region: 'auto', and an R2 API token. R2 presigned URLs authorize one operation on one object and support GET, HEAD, PUT, and DELETE. Expiration may be from 1 second to 7 days. Treat a URL as a bearer token and keep its lifetime short. R2 presigned URLs do not support HTML form POST uploads.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY
}
});
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET,
Key: 'screenshots/user-123/example.png',
ContentType: 'image/png'
});
const url = await getSignedUrl(r2, command, { expiresIn: 3600 });
console.log(url);
Use the same Content-Type on the browser PUT. A single R2 upload supports objects up to 5 GiB. Multipart uploads support up to 5 TiB, with up to 10,000 parts; each part is 5 MiB to 5 GiB. Multipart is resumable and parallelizable, while a failed single PUT must restart. See Cloudflare’s presigned URL documentation and multipart documentation.
Backblaze B2 Native API
The Native API flow is not the same as an S3 presigned PUT. First authorize and call b2_get_upload_url for a bucket. Then POST the raw screenshot bytes to the returned URL with the required headers.
import base64, hashlib, os, requests
key_id = os.environ['B2_KEY_ID']
application_key = os.environ['B2_APPLICATION_KEY']
bucket_id = os.environ['B2_BUCKET_ID']
basic = base64.b64encode(f'{key_id}:{application_key}'.encode()).decode()
auth = requests.get(
'https://api.backblazeb2.com/b2api/v2/b2_authorize_account',
headers={'Authorization': f'Basic {basic}'}, timeout=30
).json()
upload_info = requests.post(
f"{auth['apiUrl']}/b2api/v2/b2_get_upload_url",
headers={'Authorization': auth['authorizationToken'], 'Content-Type': 'application/json'},
json={'bucketId': bucket_id}, timeout=30
).json()
path = 'shot.png'
data = open(path, 'rb').read()
sha1 = hashlib.sha1(data).hexdigest()
headers = {
'Authorization': upload_info['authorizationToken'],
'X-Bz-File-Name': 'screenshots/user-123/shot.png',
'Content-Type': 'image/png',
'Content-Length': str(len(data)),
'X-Bz-Content-Sha1': sha1
}
result = requests.post(upload_info['uploadUrl'], headers=headers, data=data, timeout=120)
result.raise_for_status()
print(result.json()['fileId'])
B2 requires Content-Length; chunked transfer encoding is unsupported for b2_upload_file and b2_upload_part. When enabled, server-side encryption defaults to SSE-B2. Do not put PHI or PII in bucket names, object names, folder names, or metadata. The B2 web console supports dragging files into a bucket, with a documented 500 MB single-file limit. Public buckets are readable by anyone but never publicly writable; uploads still require credentials. See Backblaze’s Native API documentation.
cURL, Python, and Node.js upload clients
cURL
curl --fail --upload-file shot.png \
-H 'Content-Type: image/png' \
'PRESIGNED_PUT_URL'
Python
import requests
with open('shot.png', 'rb') as f:
response = requests.put(
'PRESIGNED_PUT_URL',
data=f,
headers={'Content-Type': 'image/png'},
timeout=120
)
response.raise_for_status()
Node.js
import { createReadStream } from 'node:fs';
const res = await fetch('PRESIGNED_PUT_URL', {
method: 'PUT',
headers: { 'Content-Type': 'image/png' },
body: createReadStream('shot.png'),
duplex: 'half'
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
Choosing single PUT or multipart upload
| Situation | Recommended method | Reason |
|---|---|---|
| Normal screenshots under a few dozen MB | Single presigned PUT | Simple, low protocol overhead |
| Large files or unstable networks | Multipart | Retry failed parts and resume |
| Many small screenshots | Parallel single PUTs with a concurrency limit | Easy to implement without creating thousands of multipart sessions |
| Mobile browser | Short-lived URL plus resumable client flow | Handles tab suspension and network changes better |
Delete abandoned multipart uploads with a lifecycle rule or scheduled cleanup. For single PUT retries, use a new request and an idempotent key strategy; never assume a timed-out client means the provider did not receive the object.
Security and correctness checklist
- Keep AWS, R2, or B2 credentials on the server.
- Validate MIME type, byte size, and (where practical) decoded image dimensions before signing.
- Bind the expected
Content-Typein the signature and send the same value. - Use random keys scoped to the authenticated user; never concatenate an untrusted filename into a key.
- Expire URLs quickly and return only the URL, key, and required headers.
- Restrict CORS to known application origins and methods.
- Use checksums where supported and verify with HEAD or provider metadata.
- Keep object visibility private unless a public URL is an explicit product requirement.
- Store sensitive metadata in your database, not in user-controlled object names.
- Apply lifecycle policies to temporary objects and incomplete multipart uploads.
Troubleshooting
403 SignatureDoesNotMatch or AccessDenied
The URL may be expired, the signing region or endpoint may be wrong, the IAM principal may lack PutObject, or a signed header differs from the request. Generate a fresh URL, confirm the bucket and key, use the exact endpoint, and send every signed header unchanged.
CORS error in the browser
CORS is enforced by the browser, not by cURL. Add the exact origin, PUT method, and requested headers to the bucket CORS rule. Expose ETag only if the client reads it.
R2 upload rejected despite a valid URL
R2 commonly fails when the browser sends a different Content-Type from the one signed. Sign and send the same value. Also check that the endpoint uses the correct account ID and that the URL has not exceeded its expiry.
B2 says Content-Length is missing
Do not use chunked transfer encoding. Read the file size and send an explicit Content-Length header with the raw request body.
The object exists but the application reports failure
The client may have timed out after the provider accepted the request. Treat completion as a separate step: persist the key, then verify with HEAD or a provider SDK before retrying. Use a deterministic job ID to prevent duplicate database records.
Users overwrite each other’s screenshots
Object keys are being reused. Include an authenticated user ID plus a UUID, and avoid predictable names such as latest.png unless replacement is intended.
Performance, reliability, and cost
Direct browser uploads keep image bytes off your application server and usually reduce latency. Limit concurrent uploads so a user cannot exhaust browser connections or your provider request rate. For large objects, multipart parallelism can improve throughput, but each part adds requests and cleanup work.
Storage, request, and egress pricing changes by provider, region, operation, and retrieval pattern. Compare those three dimensions for your workload instead of choosing only by storage price. Cache thumbnails separately when downloads dominate, and avoid making every original object public.
Or skip the browser setup
ScreenshotNeo returns a screenshot or PDF from one GET request, so you can send the response directly to your storage pipeline. Its API accepts full-page and element captures, device and viewport settings, dark mode, custom CSS and JavaScript, waits, headers, cookies, blocking rules, caching, signed links, async jobs, and bulk capture. Read the complete options in the ScreenshotNeo API docs.
curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Can I upload directly from the browser with cloud credentials?
Do not expose long-lived credentials. Use a short-lived, object-specific presigned URL or a provider upload URL.
Should I use POST instead of PUT?
Presigned PUT is the portable approach for S3 and R2. R2 presigned URLs do not support HTML form POST uploads.
When should I verify with HEAD?
Verify after the client reports success and after recoverable timeouts. This prevents marking a screenshot complete before the object is actually available.
Is Backblaze B2 Native API interchangeable with S3?
No. B2 has an S3-compatible interface, but the Native API upload flow uses b2_get_upload_url and b2_upload_file with an explicit content length.


