← Back to Blogs LWC / Integration, Developer
Lightning Web Components × Amazon S3

Ship files from LWC straight into S3 — with one tiny slice of Apex.

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.

⏱ ~15 min read🧩 1 Apex class📦 0 file bytes through Salesforce🗓 July 2026
The problem

Why routing files through Salesforce hurts

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.

✗ File bytes via Apex

Salesforce as a middleman

Every byte squeezes through heap, callout, and payload limits. Slow, fragile, and capped at toy-sized files.

~4–6 MB practical ceiling😬
✓ Presigned URL

Browser → S3, direct

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.

Up to 5 GB per PUT🚀
💡 What is a presigned URL?

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.

Architecture

The three-step handshake

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.

SALESFORCE AWS LWC drag & drop UI Apex signs URL only ~60 lines · <50 ms Amazon S3 bucket: lwc-demo-uploads ① name + type (imperative Apex call) ② presigned URL (expires in 5 min) ③ HTTP PUT — raw bytes, browser → S3 (Salesforce never sees them) meta 🔑 bytes

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.

Part 1 · AWS side (10 minutes)

Prep the bucket: CORS + a scoped IAM user

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:

1 · CORS — let your Salesforce domain talk to the bucket

The browser upload is a cross-origin PUT, so S3 must whitelist your Lightning domain. Bucket → Permissions → CORS:

jsonbucket-cors.json
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedOrigins": [
      "https://yourdomain.lightning.force.com",
      "https://yourdomain--sandbox.sandbox.lightning.force.com"
    ],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

2 · IAM — a user that can do exactly one thing

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".

jsoniam-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "LwcUploadsOnly",
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::lwc-demo-uploads/uploads/*"
    }
  ]
}
🔐 Where do the keys live in Salesforce?

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.

Part 2 · The only Apex you'll write

One class. One method. Zero callouts.

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.

apexS3PresignService.cls
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);
    }
}
  • No @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.
  • 5-minute expiry (X-Amz-Expires=300) keeps the URL useless to anyone who finds it later in a log.
  • The replaceAll on the key blocks ../ shenanigans and pins every upload under uploads/ — matching the IAM policy exactly.
Part 3 · The LWC does the real work

Drag, drop, sign, PUT — all in the browser

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.

jss3FileUploader.js
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' : '');
    }
}
htmls3FileUploader.html
<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>
csss3FileUploader.css
.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; }
xmls3FileUploader.js-meta.xml
<?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>
⚠ Don't skip this: CSP Trusted URL

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.

Part 4 · What the user actually sees

Anatomy of the rendered component

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:

https://yourdomain.lightning.force.com/lightning/r/Account/001…/view
Account
Acme Infrastructure Pvt. Ltd.
1 2 3 4 5
🗂
Upload to Amazon S3
📄 site_survey.pdf
Drag files here
or
Browse files
site_survey.pdf18.4 MB
PUT → s3.ap-south-1… · 64%
drone_footage.mp448.7 MB
PUT → s3.ap-south-1… · 23%
Pin → template mapping
1
<lightning-card>Base SLDS container — gives you the header, icon (doctype:zip), and Lightning look-and-feel for free.
2
Drop zone <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.
3
Hidden <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.
4
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.
5
<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.

The bundle on disk · and the bucket after upload

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:

force-app/main/default
├─ classes/
│  ├─ S3PresignService.cls ← all the Apex
│  └─ S3PresignService.cls-meta.xml
└─ lwc/
    └─ s3FileUploader/
       ├─ s3FileUploader.html ← the UI above
       ├─ s3FileUploader.js ← sign + PUT
       ├─ s3FileUploader.css
       └─ s3FileUploader.js-meta.xml
S3  ›  lwc-demo-uploads  ›  uploads/
NameSizeTypeLast modified
1721286912041_site_survey.pdf18.4 MBapplication/pdfJust now
1721286917388_drone_footage.mp448.7 MBvideo/mp4Just now
1721201455102_contract_v2.docx824 KBapplication/vnd…Yesterday
Objects (3) · Block Public Access: ✔ ON — presigned URLs don't need public buckets
🧭 Where to place it

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.

Playground

Feel the flow — a simulated run

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.

📁 Drop anything here
…or just click / press Enter to simulate an upload
Simulation only — no network calls leave this page.
Field notes

Five gotchas that eat an afternoon each

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.

That's the whole trick.

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.

🪣 1 bucket + CORS🔐 1 scoped IAM user✎ 1 Apex class⚡ 1 LWC🛡 1 CSP Trusted URL
Built with Lightning ⚡ and a very small amount of signing math · Happy shipping!

Welcome Back

OR
Don't have an account? Sign up