Securely import files from Amazon S3 in browsers
Keep AWS credentials on the server or operator’s machine, issue a short-lived presigned GET URL for one authorized object, and let the browser fetch its bytes from Amazon S3. This example imports an object of up to 1 MiB and checks its complete contents against a trusted byte length and SHA-256 before reporting success.
Why import directly from Amazon S3 in the browser?
The file travels from S3 to the browser, so your application server does not relay the response body. The server still decides who may receive a URL for which object. This is a small operator-run example, with a manual handoff to a local page; it does not implement application sign-in or a public signing endpoint. A production application must authenticate the caller and authorize the specific object before issuing its URL.
A presigned URL is a bearer capability: anyone who obtains it can use it while it remains valid, including outside a browser. It can be reused. CORS controls whether JavaScript on another origin can read the response; S3 permissions and the signature control object access.
Setting up your S3 bucket
Use a private, general-purpose S3 bucket in us-east-1, with Block Public Access enabled and a
small existing object. Have its bucket name, exact key, 12-digit bucket owner account ID, byte
length, and SHA-256 from the trusted producer’s upload record. The accepted size is zero through
1,048,576 bytes, inclusive. The checksum must describe the stored object bytes, rather than a hash
computed from whatever the browser happens to receive. For this example, use an object without
HTTP content encoding, so browser decoding does not change those bytes.
The operator needs an already configured AWS SDK credential source with permission to read that
object. For this unversioned read, that includes s3:GetObject on its exact object ARN. Additional
requirements can apply, such as KMS permissions for SSE-KMS objects; see the
GetObject authorization requirements.
Do not make the object public to fix a signing failure.
For a dedicated test bucket, use this JSON CORS configuration for the local page. In an existing bucket, merge the rule into its configuration and inspect other rules for broader origins; replacing the configuration can disrupt other applications. The example sends GET without custom headers and does not inspect ETag, so it needs neither wildcard request headers nor exposed response headers. See S3’s CORS elements.
[
{
"AllowedOrigins": ["http://127.0.0.1:8787"],
"AllowedMethods": ["GET"],
"MaxAgeSeconds": 0
}
]
An origin includes the scheme, hostname, and port. localhost and 127.0.0.1 are different origins.
Use the exact URL printed by the local server below, and adjust the CORS rule if you select another
port. In production, serve the application over HTTPS and allow its exact HTTPS origin. Enabling
CORS leaves S3 ACLs and policies in effect.
Using the AWS SDK for JavaScript
Use a maintained, patched Node.js 26 release, separately installed Corepack, and a current browser with Fetch streams and Web Crypto. The setup and operator scripts were checked with Node.js 26.12.1, Corepack 0.36.0, Yarn 4.12.0, and AWS SDK packages 3.1073.0; the page was checked in Chromium 145. Keep Node patched using its security release notes. The browser uses platform APIs and receives no AWS secret access key.
In a writable directory, paste the following into Bash. It creates a new project and leaves your
shell in its original directory. It refuses an existing s3-browser-import directory. The local
Yarn configuration deliberately selects a local node_modules installation and ignores an enclosing
project’s Yarn executable path.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir s3-browser-import &&
cd s3-browser-import &&
cat > package.json <<'JSON' &&
{
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@aws-sdk/client-s3": "3.1073.0",
"@aws-sdk/s3-request-presigner": "3.1073.0"
}
}
JSON
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'ignorePath: true' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn@4.12.0 install
)
A failed installation leaves the new directory for inspection. Save the next three files inside
s3-browser-import. Run subsequent commands from that directory.
Issue a ticket on the operator’s machine
Save this as sign.ts. Its arguments are bucket, key, bucket owner, expected byte length, expected
SHA-256, and expiry in seconds. This signer accepts bucket names made from lowercase letters,
digits, and hyphens. The expiry must be an integer from 1 through 300. All metadata comes
from the trusted object record; signing alone does not check that the object exists or that access
will succeed.
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const [bucket, key, owner, sizeText, sha256, expiryText] = args
const size = Number(sizeText)
const expiresIn = Number(expiryText)
if (
args.length !== 6 || !bucket || !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket) ||
!key || new TextEncoder().encode(key).length > 1024 || !/^\d{12}$/.test(owner ?? '') ||
!/^\d+$/.test(sizeText ?? '') || !Number.isSafeInteger(size) || size > 1_048_576 ||
!/^[a-f0-9]{64}$/.test(sha256 ?? '') || !/^\d+$/.test(expiryText ?? '') ||
!Number.isSafeInteger(expiresIn) || expiresIn < 1 || expiresIn > 300
) {
throw new Error('Invalid ticket arguments')
}
const client = new S3Client({
region: 'us-east-1',
endpoint: 'https://s3.us-east-1.amazonaws.com',
forcePathStyle: true,
})
try {
const command = new GetObjectCommand({ Bucket: bucket, Key: key, ExpectedBucketOwner: owner })
const url = await getSignedUrl(client, command, { expiresIn })
process.stdout.write(`${JSON.stringify({ url, size, sha256 })}\n`)
} finally {
client.destroy()
}
}
main().catch(() => {
console.error('Could not issue ticket. Check the six arguments and operator credentials.')
process.exitCode = 1
})
Set these shell variables to your trusted record: S3_BUCKET, S3_KEY, S3_OWNER, S3_BYTES, and
S3_SHA256. Then run the literal entrypoint, which prints one JSON ticket:
node sign.ts "$S3_BUCKET" "$S3_KEY" "$S3_OWNER" "$S3_BYTES" "$S3_SHA256" 60
The SDK’s request presigner uses the operator’s credential source. Keep that source on the operator’s machine. Do not paste credentials into the page or add a signing endpoint that accepts arbitrary bucket names and keys without authorization.
Import and verify the object in the browser
Save this complete page as index.html. The ticket’s size bounds the application’s object buffer;
the stream must end at exactly that size, and its SHA-256 must match before the helper returns bytes.
A 30-second timer requests cancellation during the fetch and response-body reads. Keep the tab
active while importing; background tabs can delay timers.
A slower transfer can fail even below the size limit. Only one import runs at a time.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<title>Private S3 import</title>
<link rel="icon" href="data:,">
<h1>Private S3 import</h1>
<form id="import-form">
<label for="ticket">Import ticket</label>
<textarea id="ticket" rows="6" cols="30" autocomplete="off" spellcheck="false"></textarea>
<button type="submit">Import</button>
</form>
<p role="status" id="status">Ready.</p>
<script>
async function importObject(ticket) {
if (!ticket || typeof ticket.url !== 'string' ||
!Number.isSafeInteger(ticket.size) || ticket.size < 0 || ticket.size > 1_048_576 ||
typeof ticket.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ticket.sha256)) {
throw new Error('Invalid ticket')
}
const url = new URL(ticket.url)
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
throw new Error('Invalid object URL')
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 30_000)
let reader
try {
const response = await fetch(url.href, {
mode: 'cors', credentials: 'omit', cache: 'no-store', redirect: 'error',
referrerPolicy: 'no-referrer', signal: controller.signal,
})
if (!response.ok || !response.body) throw new Error('Object read failed')
reader = response.body.getReader()
const bytes = new Uint8Array(ticket.size)
let offset = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value.length > bytes.length - offset) throw new Error('Object exceeds expected size')
bytes.set(value, offset)
offset += value.length
}
if (offset !== bytes.length) throw new Error('Incomplete object')
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
if (actual !== ticket.sha256) throw new Error('Object checksum mismatch')
return bytes
} finally {
clearTimeout(timer)
controller.abort()
if (reader) {
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
}
const form = document.getElementById('import-form')
const input = document.getElementById('ticket')
const button = form.querySelector('button')
const status = document.getElementById('status')
let busy = false
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (busy) return
busy = true
button.disabled = true
input.disabled = true
status.textContent = 'Importing…'
const raw = input.value
input.value = ''
try {
const bytes = await importObject(JSON.parse(raw))
status.textContent = `Imported ${bytes.length} verified bytes.`
} catch {
status.textContent = 'Import failed. Check the ticket, object metadata, expiry, and CORS.'
} finally {
busy = false
button.disabled = false
input.disabled = false
}
})
</script>
</html>
importObject returns a Uint8Array for your application to consume; the form demonstrates the
verified result as a byte count. It does not save a file to disk or retain the imported bytes after
the handler finishes. It clears the pasted ticket and uses no browser storage, third-party scripts,
or object URLs. The browser and hashing implementation still have their own buffers, so 1 MiB is an
object limit rather than a total browser memory ceiling.
Serve the page locally
Save this as serve.ts. It serves only the page on loopback and disables response caching. It does
not expose operator credentials or implement signing.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const port = args.length === 0 ? 8787 : Number(args[0])
if (args.length > 1 || (args.length === 1 && !/^\d+$/.test(args[0])) ||
!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('Invalid port')
}
const html = await readFile(new URL('./index.html', import.meta.url))
const server = createServer((request, response) => {
const ok = request.method === 'GET' && request.url === '/'
response.writeHead(ok ? 200 : 404, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
})
response.end(ok ? html : 'Not found')
})
server.on('error', () => {
console.error('Could not start server. Check whether the port is available.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Open http://127.0.0.1:${address.port}/`)
}
})
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
for (const signal of signals) {
process.once(signal, () => {
server.close()
server.closeAllConnections()
})
}
}
main().catch(() => {
console.error('Could not start server. Check the port and index.html.')
process.exitCode = 1
})
node serve.ts 8787
Open http://127.0.0.1:8787/, paste the fresh JSON into Import ticket,
and select Import. A successful import shows
Imported 16 verified bytes. for a 16-byte object, or its actual
byte count. Stop the foreground server with Ctrl+C when finished. Use another port if 8787 is
occupied, and update both the page URL and bucket CORS rule.
Performance optimization tips
This example reads one bounded object into memory and hashes it before use. Limit concurrent imports if you integrate the helper into a larger application. For files beyond that application’s memory budget, choose a streaming consumer or a normal browser download instead; opening a download URL does not give JavaScript the imported bytes or confirm that a file was saved.
Security best practices
Treat the whole ticket as sensitive. Pass it only to the intended recipient over a protected channel; keep it out of application URLs, analytics, logs, shared clipboard history, and browser storage. Clearing the form cannot revoke copies already made. The query string contains signing information and may include a temporary session token, even though it does not contain the AWS secret access key. Keep the operator’s credential source separate from the page directory and avoid terminal recording when displaying tickets.
S3 checks URL expiry when a request starts. An in-progress download may continue after expiry; a restart after expiry can fail. Temporary credentials can shorten the URL’s lifetime, and permission changes can also prevent access. The example’s 60-second URL is not a mechanism for erasing downloaded data. Its checksum detects a mismatch with the trusted producer record; it does not establish the trustworthiness of an attacker-supplied ticket.
Common issues and solutions
- Ticket issuance fails: Check the six arguments, the expected metadata, and the operator’s credential source. Do not move credentials into the browser to bypass the failure.
- The browser reports failure: Its public message deliberately groups failures. Check whether the ticket has expired, whether the signer can read the exact key with the supplied owner ID, and whether the page’s origin matches CORS. A blocked CORS response may hide its HTTP status from JavaScript; changing CORS does not grant missing S3 permissions.
- A readable response still fails verification: Confirm the producer’s size and SHA-256, check for object replacement or HTTP content encoding, and issue a new ticket for the intended bytes. A truncated response, checksum mismatch, or canceled transfer must not be reported as an imported object.
Conclusion
For application-managed imports that run on Transloadit’s servers rather than in the browser, see our S3 import Robot. For this browser workflow, keep object authorization with the issuer and pass only the short-lived ticket to the recipient.
Rather not run this yourself?
Transloadit imports files from S3, Dropbox, URLs and more, ready for processing in the same API call.
