Secure AJAX file uploads: best practices and techniques
Securely handling file uploads is critical in modern web development. As AJAX file uploads become increasingly prevalent, protecting your application from potential vulnerabilities is essential. In this post, we explore modern best practices and techniques—including using the Fetch API, chunked uploads, and comprehensive error handling—to build secure file upload systems.
Understanding AJAX file uploads
AJAX (Asynchronous JavaScript and XML) enables web applications to send and receive data from the server asynchronously without refreshing the page. When implementing AJAX file uploads, it is important to address security considerations on both the client and server sides.
Browser compatibility
Modern browsers provide robust support for file uploads through various APIs:
- The FormData API is available in all modern browsers.
- The Fetch API is the recommended approach over legacy techniques.
- The File API offers advanced file handling capabilities.
- XMLHttpRequest supports request upload progress events, which Fetch does not provide directly.
For more details, refer to the MDN documentation on FormData and Fetch API.
Implementing modern file uploads
Using the fetch API with async/await
The Fetch API, combined with async/await, offers a clean method for uploading files. The /upload
endpoint shown later accepts one multipart file field and returns JSON. Serve the client from the
same origin, or configure CORS for your application:
const uploadFile = async (file, idempotencyKey) => {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData,
headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined,
})
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error('Upload error:', error)
throw error
}
}
Tracking upload progress
Use XMLHttpRequest.upload
events to track bytes sent in the request. Reading response.body measures the response download,
not the file upload. A progress value of 100% means the body was sent; wait for the server response
before reporting success:
const uploadWithProgress = (file, onProgress = console.log) => {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress((event.loaded / event.total) * 100)
}
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 120000
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new Error(`Upload failed (HTTP ${xhr.status})`))
}
}
xhr.onerror = () => reject(new Error('Upload failed: network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new DOMException('Upload canceled', 'AbortError'))
xhr.send(formData)
})
}
Handling chunked uploads
For large files, breaking the upload into smaller chunks improves reliability in unstable network
conditions. This illustrates a separate endpoint contract, not endpoints implemented by the Multer
server below. The server must bind fileId to the authenticated user, validate chunk indexes,
counts, and aggregate size, and assemble all chunks before acknowledging completion. It must safely
expire incomplete uploads and make repeated chunks and completion requests idempotent. Use a
tus server and client when you need a standardized
resumable protocol:
const CHUNK_SIZE = 1024 * 1024 // 1MB chunks
const uploadLargeFile = async (file) => {
if (file.size === 0) throw new Error('Please select a nonempty file')
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
const fileId = crypto.randomUUID()
for (let chunk = 0; chunk < totalChunks; chunk++) {
const start = chunk * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const fileChunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', fileChunk)
formData.append('fileId', fileId)
formData.append('chunkIndex', chunk)
formData.append('totalChunks', totalChunks)
formData.append('fileSize', file.size)
try {
const response = await fetch('/upload/chunk', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Chunk ${chunk} failed (HTTP ${response.status})`)
}
} catch (error) {
console.error(`Chunk ${chunk} failed:`, error)
throw error
}
}
// Finalize the upload
const response = await fetch('/upload/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ fileId }),
})
if (!response.ok) {
throw new Error(`Upload completion failed (HTTP ${response.status})`)
}
return response
}
Handling multiple file uploads
Uploading multiple files concurrently can improve performance. By using the input's multiple
attribute and modern JavaScript, you can handle a small selection concurrently. This example caps
each selection at five files; use a bounded queue for larger batches:
const uploadMultipleFiles = async (files) => {
if (files.length > 5) throw new Error('Select up to five files at a time')
const uploadPromises = Array.from(files).map((file) => {
const formData = new FormData()
formData.append('file', file)
return fetch('/upload', { method: 'POST', body: formData })
.then((response) => {
if (!response.ok) {
throw new Error(`Upload failed for ${file.name}`)
}
return response.json()
})
.catch((error) => {
console.error(`Error uploading ${file.name}:`, error)
throw error
})
})
return Promise.all(uploadPromises)
}
Drag and drop file upload
Enhance user experience by implementing drag-and-drop file uploads. The following example establishes a drop zone that responds to drag events:
const dropZone = document.getElementById('drop-zone')
dropZone.addEventListener('dragover', (e) => {
e.preventDefault()
dropZone.classList.add('highlight')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('highlight')
})
dropZone.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('highlight')
const files = e.dataTransfer.files
try {
const results = await uploadMultipleFiles(files)
console.log('Files uploaded:', results)
} catch (error) {
console.error('Error during drag-and-drop upload:', error)
}
})
Client-side file type validation
Before uploading, validate file types using the File API to provide immediate feedback to users:
const validateFileType = (file) => {
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
alert(`Invalid file type: ${file.type}`)
return false
}
return true
}
const handleFileInput = async (event) => {
const files = event.target.files
try {
for (const file of files) {
if (validateFileType(file)) await uploadFile(file)
}
} catch {
alert('Unable to upload the selected file. Please try again.')
}
}
document.getElementById('file-input').addEventListener('change', handleFileInput)
Server-side implementation
This Node.js example uses disk storage, request limits, and sanitized errors. Multer 2.3.0 fixes the denial-of-service issue described in the official advisory. Install the dependencies:
npm install express@5 multer@2.3.0 express-rate-limit@8
Run this as a CommonJS server file with Node.js. Store uploads/ outside the web root. This example
accepts files into private storage; authorization, CSRF protection for cookie-based sessions,
content inspection, and malware scanning must be added before files are published or processed. The
MIME value supplied by the client is only a preliminary filter, not proof of file content.
const express = require('express')
const multer = require('multer')
const path = require('path')
const crypto = require('crypto')
const app = express()
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// Generate a secure random filename
crypto.randomBytes(16, (err, raw) => {
if (err) return cb(err)
cb(null, raw.toString('hex') + path.extname(file.originalname))
})
},
})
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (allowedTypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('Invalid file type'), false)
}
}
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
files: 1,
fields: 0,
parts: 2,
},
fileFilter,
})
const { rateLimit } = require('express-rate-limit')
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 uploads per window
})
app.post('/upload', uploadLimiter, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' })
}
res.json({ message: 'File accepted into private storage.' })
})
// Error handling middleware
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400
return res.status(status).json({ error: 'File exceeds an upload limit.' })
}
if (err.message === 'Invalid file type') {
return res.status(400).json({ error: 'Unsupported file type.' })
}
console.error('Upload failed', { code: err.code ?? 'UNKNOWN' })
res.status(500).json({ error: 'Unable to upload the file.' })
})
app.listen(3000)
Security best practices
Protect your file upload system by implementing robust security measures:
- Set a Content Security Policy (CSP) on the HTML responses. Register this middleware before page routes, and load scripts from separate files. This baseline disallows inline scripts; tailor a nonce- or hash-based policy to your application if inline scripts are required:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'",
)
next()
})
- Give users early feedback with browser checks, then enforce size and content rules on the server:
const validateFile = (file) => {
const maxSize = 5 * 1024 * 1024 // 5MB
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (file.size > maxSize) {
throw new Error('File too large')
}
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type')
}
}
-
Configure secure storage:
- Store files outside the web root.
- Use randomized filenames.
- Set proper file permissions.
- Consider cloud storage options for scalability.
-
Implement rate limiting to curb abuse.
-
Enforce HTTPS to encrypt file transfers.
-
Use signed URLs for secure, direct-to-storage uploads.
-
Integrate virus scanning using reliable cloud-based solutions.
Error handling and recovery
Robust error handling improves user experience. Implement retry logic for transient network
failures. A lost response can occur after the server has stored a file, so use this only with a
server that deduplicates retries using an application-defined idempotency key. The Multer server
above does not implement deduplication; add persistent handling of Idempotency-Key, scoped to the
authenticated user, before enabling this wrapper:
const uploadWithRetry = async (file, maxRetries = 3) => {
if (!Number.isInteger(maxRetries) || maxRetries < 1) {
throw new Error('maxRetries must be a positive integer')
}
const idempotencyKey = crypto.randomUUID()
let attempts = 0
while (attempts < maxRetries) {
try {
const result = await uploadFile(file, idempotencyKey)
return result
} catch (error) {
attempts++
if (attempts === maxRetries) {
throw new Error(`Upload failed after ${maxRetries} attempts`, { cause: error })
}
// Exponential backoff before retrying
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempts) * 1000))
}
}
}
Conclusion
Secure file uploads require thorough client- and server-side measures. By implementing modern techniques for validation, error handling, and efficient uploads, you can build a robust system that mitigates common vulnerabilities.
For upload progress and resumable transfers, consider Uppy with an uploader plugin matched to your server protocol.
