No base64 round-trips. No heap limit anxiety. Your Apex signs a URL in milliseconds, and the browser does all the heavy lifting — complete with drag & drop and real progress bars.
The classic pattern — read the file in LWC, base64 it, pass it to Apex, and let Apex call S3 — works right up until someone uploads a real file. Base64 inflates payloads by ~33%, Apex has a 6 MB heap limit (12 MB async), and callout payloads and governor limits box you in further. A 25 MB site survey PDF? Dead on arrival.
Every byte squeezes through heap, callout, and payload limits. Slow, fragile, and capped at toy-sized files.
Apex only signs a URL (a few hundred bytes of string math). The browser streams the file straight to S3 — up to 5 GB in a single PUT.
A normal S3 URL, plus a cryptographic signature baked into its query string. It says: "whoever holds this exact URL may PUT this exact object, until this exact expiry." No AWS credentials ever reach the browser — the signature is computed server-side and expires in minutes.
Blue is everything that happens inside Salesforce; amber is AWS. Notice how skinny the Apex lane is — it touches metadata, never file bytes. Watch the loop: file picked → URL signed → browser uploads direct.
The user drops files onto the LWC. For each file, the component calls a single imperative Apex method with just two strings: the object key (folder/filename) and the MIME type. Total payload: a few dozen bytes.
Create a bucket (say lwc-demo-uploads), keep Block Public Access ON — presigned URLs work fine on private buckets; that's the whole point. Then two small configs:
The browser upload is a cross-origin PUT, so S3 must whitelist your Lightning domain. Bucket → Permissions → CORS:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": [
"https://yourdomain.lightning.force.com",
"https://yourdomain--sandbox.sandbox.lightning.force.com"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Create a programmatic IAM user whose keys Apex will use for signing. Scope it ruthlessly — PutObject on one prefix of one bucket, nothing else. If the keys ever leak, the blast radius is "someone can add files to a folder".
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "LwcUploadsOnly",
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::lwc-demo-uploads/uploads/*"
}
]
}
Never hardcode them. Store the Access Key + Secret in Protected Custom Metadata or a Named Credential's custom headers/merge fields. The snippets below read from Custom Metadata (AWS_Setting__mdt) so admins can rotate keys without a deploy.
This is the entire server-side footprint. It builds an AWS Signature V4 presigned URL using nothing but Crypto.generateMac() and string concatenation — it doesn't even make an HTTP callout, so there's no Remote Site Setting for Apex, no callout limits, and it runs in a few milliseconds.
public with sharing class S3PresignService {
// Pull config from Protected Custom Metadata — never hardcode secrets
private static final AWS_Setting__mdt CFG = AWS_Setting__mdt.getInstance('S3');
private static final String BUCKET = 'lwc-demo-uploads';
private static final String REGION = 'ap-south-1';
@AuraEnabled
public static String generatePresignedUrl(String objectKey, String contentType) {
// Basic hygiene: pin uploads under one prefix, strip path tricks
objectKey = 'uploads/' + objectKey.replaceAll('[^a-zA-Z0-9._-]', '_');
String accessKey = CFG.Access_Key__c;
String secretKey = CFG.Secret_Key__c;
Datetime nowDt = Datetime.now();
String amzDate = nowDt.formatGmt('yyyyMMdd\'T\'HHmmss\'Z\'');
String dateStamp = nowDt.formatGmt('yyyyMMdd');
String host = BUCKET + '.s3.' + REGION + '.amazonaws.com';
String scope = dateStamp + '/' + REGION + '/s3/aws4_request';
String encodedKey = EncodingUtil.urlEncode(objectKey, 'UTF-8')
.replace('+', '%20').replace('%2F', '/');
// Query params must be sorted alphabetically for the signature
Map<String, String> q = new Map<String, String>{
'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
'X-Amz-Credential' => EncodingUtil.urlEncode(accessKey + '/' + scope, 'UTF-8'),
'X-Amz-Date' => amzDate,
'X-Amz-Expires' => '300', // URL dies in 5 minutes
'X-Amz-SignedHeaders' => 'host'
};
List<String> names = new List<String>(q.keySet());
names.sort();
String query = '';
for (String n : names) {
query += (query == '' ? '' : '&') + n + '=' + q.get(n);
}
// 1) Canonical request
String canonical = 'PUT\n'
+ '/' + encodedKey + '\n'
+ query + '\n'
+ 'host:' + host + '\n\n'
+ 'host\n'
+ 'UNSIGNED-PAYLOAD';
// 2) String to sign
String stringToSign = 'AWS4-HMAC-SHA256\n' + amzDate + '\n' + scope + '\n'
+ EncodingUtil.convertToHex(
Crypto.generateDigest('SHA-256', Blob.valueOf(canonical)));
// 3) Derive the signing key (chained HMACs)
Blob kDate = hmac(dateStamp, Blob.valueOf('AWS4' + secretKey));
Blob kRegion = hmac(REGION, kDate);
Blob kService = hmac('s3', kRegion);
Blob kSigning = hmac('aws4_request', kService);
// 4) Sign + assemble
String signature = EncodingUtil.convertToHex(
Crypto.generateMac('hmacSHA256', Blob.valueOf(stringToSign), kSigning));
return 'https://' + host + '/' + encodedKey + '?' + query
+ '&X-Amz-Signature=' + signature;
}
private static Blob hmac(String data, Blob key) {
return Crypto.generateMac('hmacSHA256', Blob.valueOf(data), key);
}
}
@AuraEnabled(cacheable=true). Every URL is unique (timestamped + signed), so caching would hand out expired signatures.UNSIGNED-PAYLOAD is what makes this work without knowing the file's hash up front — S3 skips body-hash validation but still enforces identity, expiry, method, and key.X-Amz-Expires=300) keeps the URL useless to anyone who finds it later in a log.replaceAll on the key blocks ../ shenanigans and pins every upload under uploads/ — matching the IAM policy exactly.The component owns the whole experience: drag & drop, multi-file queueing, per-file progress bars via xhr.upload.onprogress, retries, and status chips. Apex is called exactly once per file, for ~40 bytes of metadata.
import { LightningElement, track } from 'lwc';
import generatePresignedUrl from '@salesforce/apex/S3PresignService.generatePresignedUrl';
const MAX_SIZE = 100 * 1024 * 1024; // 100 MB — your call, S3 allows 5 GB/PUT
export default class S3FileUploader extends LightningElement {
@track files = []; // [{ id, name, size, pct, status, error }]
dragging = false;
// ---------- drag & drop ----------
handleDragOver(e) { e.preventDefault(); this.dragging = true; }
handleDragLeave() { this.dragging = false; }
handleDrop(e) {
e.preventDefault();
this.dragging = false;
this.queueFiles(e.dataTransfer.files);
}
handleBrowse(e) { this.queueFiles(e.target.files); }
// ---------- pipeline ----------
queueFiles(fileList) {
[...fileList].forEach((file) => {
const item = {
id: crypto.randomUUID(),
name: file.name,
size: this.pretty(file.size),
pct: 0,
status: file.size > MAX_SIZE ? 'error' : 'signing',
error: file.size > MAX_SIZE ? 'File exceeds 100 MB limit' : null
};
this.files = [...this.files, item];
if (!item.error) this.upload(file, item.id);
});
}
async upload(file, id) {
try {
// ① The ONLY Apex interaction — two strings in, one URL out
const url = await generatePresignedUrl({
objectKey: `${Date.now()}_${file.name}`,
contentType: file.type || 'application/octet-stream'
});
// ② Raw bytes: browser → S3. Salesforce is out of the loop.
this.patch(id, { status: 'uploading' });
await this.putToS3(url, file, (pct) => this.patch(id, { pct }));
this.patch(id, { status: 'done', pct: 100 });
} catch (err) {
this.patch(id, { status: 'error', error: err.message || 'Upload failed' });
}
}
putToS3(url, file, onProgress) {
// XMLHttpRequest (not fetch) so we get real upload progress events
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => xhr.status === 200
? resolve()
: reject(new Error(`S3 replied ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network / CORS error — check CSP + bucket CORS'));
xhr.send(file); // the File object itself — no base64, no copies
});
}
// ---------- helpers ----------
patch(id, delta) {
this.files = this.files.map((f) => (f.id === id ? { ...f, ...delta } : f));
}
pretty(b) {
if (b < 1024) return b + ' B';
const u = ['KB', 'MB', 'GB'];
let i = -1;
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
return b.toFixed(1) + ' ' + u[i];
}
get dropZoneClass() {
return 'dropzone' + (this.dragging ? ' dragging' : '');
}
}
<template>
<lightning-card title="Upload to Amazon S3" icon-name="doctype:zip">
<div class="slds-p-around_medium">
<!-- Drop zone -->
<div class={dropZoneClass}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}>
<lightning-icon icon-name="utility:upload" size="medium"></lightning-icon>
<p class="dz-title">Drag files here</p>
<p class="dz-sub">or</p>
<label class="browse-btn">
Browse files
<input type="file" multiple onchange={handleBrowse} class="hidden-input" />
</label>
</div>
<!-- Queue -->
<template for:each={files} for:item="f">
<div key={f.id} class="file-row">
<div class="file-head">
<span class="file-name">{f.name}</span>
<span class="file-size">{f.size}</span>
</div>
<lightning-progress-bar value={f.pct} size="small">
</lightning-progress-bar>
<template lwc:if={f.error}>
<p class="file-error">{f.error}</p>
</template>
</div>
</template>
</div>
</lightning-card>
</template>
.dropzone {
border: 2px dashed #c9c9c9;
border-radius: 12px;
padding: 2.5rem 1rem;
text-align: center;
transition: border-color .2s, background .2s, transform .2s;
}
.dropzone.dragging {
border-color: #0176d3;
background: rgba(1, 118, 211, .06);
transform: scale(1.01);
}
.dz-title { font-weight: 700; margin-top: .5rem; }
.dz-sub { color: #706e6b; margin: .25rem 0; }
.browse-btn {
display: inline-block;
padding: .45rem 1.2rem;
border-radius: 999px;
background: #0176d3;
color: #fff;
cursor: pointer;
font-weight: 600;
}
.hidden-input { display: none; }
.file-row { margin-top: 1rem; }
.file-head { display: flex; justify-content: space-between; margin-bottom: .3rem; }
.file-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.file-size { color: #706e6b; font-size: .8rem; }
.file-error{ color: #ba0517; font-size: .8rem; margin-top: .3rem; }
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__AppPage</target>
<target>lightning__HomePage</target>
</targets>
</LightningComponentBundle>
Lightning's Content Security Policy blocks the browser's PUT unless you whitelist the bucket. Setup → Security → CSP Trusted URLs → New, add https://lwc-demo-uploads.s3.ap-south-1.amazonaws.com, and tick connect-src. Forget this and you'll get a vague "network error" that's actually CSP.
Here's the component as it appears on a Lightning record page — every numbered pin maps a visual region back to the exact markup in s3FileUploader.html. Use the state buttons to flip through the four moments of its life:
doctype:zip), and Lightning look-and-feel for free.<div class={dropZoneClass}>Listens to ondragover / ondragleave / ondrop. The getter swaps in the .dragging class, which the CSS turns into the blue highlight you see in state 2.<input type="file" multiple>Wrapped in a styled <label> so "Browse files" looks like a button. onchange={handleBrowse} feeds the same queue as drag & drop.template for:each={files}One row per queued file, keyed by a crypto.randomUUID(). Rows appear the instant a file is dropped — while it's still being signed.<lightning-progress-bar value={f.pct}>Driven live by xhr.upload.onprogress. Blue while streaming to S3, and the status line flips to green on the 200 OK.Four files in the component bundle (plus the Apex class), and here's what lands in the AWS console once those two uploads finish — note the timestamp prefix from the JS and the uploads/ prefix pinned by Apex:
| Name | Size | Type | Last modified |
|---|---|---|---|
| 1721286912041_site_survey.pdf | 18.4 MB | application/pdf | Just now |
| 1721286917388_drone_footage.mp4 | 48.7 MB | video/mp4 | Just now |
| 1721201455102_contract_v2.docx | 824 KB | application/vnd… | Yesterday |
Because the meta.xml exposes lightning__RecordPage, lightning__AppPage, and lightning__HomePage, the component shows up in Lightning App Builder under Custom — drag it onto any page, hit Save, Activate, done. To tie uploads to the record you're on, add @api recordId and prefix the object key with it.
This sandbox mimics exactly what your users will see: drop (or click to add) a fake file and watch the two phases play out — a blue flash while Apex signs, then the amber-to-blue bar as bytes stream to S3. Nothing is uploaded anywhere; it's a simulation of the real component's UX.
xhr.onerror fires with no detail when Lightning's CSP silently blocks the request. Open DevTools → Console; if you see a connect-src violation, add the bucket URL as a CSP Trusted URL. This is the #1 support question for this pattern.
With SignedHeaders=host only, you're free to send any Content-Type. But if you ever add content-type to the signed headers in Apex, the browser's header must match byte for byte — including the charset suffix some browsers append — or S3 returns 403 SignatureDoesNotMatch.
Postman doesn't do CORS — browsers do. If it works in Postman but not in the LWC, your bucket CORS AllowedOrigins doesn't match the Lightning domain exactly (watch for *.lightning.force.com vs enhanced domains, and sandboxes).
The signature embeds X-Amz-Date. If a user keeps the tab open past the expiry, or queues a huge batch, later PUTs 403. Fix: sign each URL just before its PUT (as the code above does), not all at once when files are dropped.
A single presigned PUT tops out at 5 GB. Beyond that, S3 multipart upload applies — each part gets its own presigned URL. Same recipe, more URLs, plus an initiate/complete call. Worth it only if you genuinely ship multi-gig files.
Apex signs. The browser ships. S3 stores. Your users get GB-scale uploads with real progress bars, and your org's limits never even notice.