Mastering file uploads: comprehensive guide for developers
A file upload can reach your server and still fail before anything is stored. This guide gives you local image-upload handlers for Node.js, Flask, and PHP, an authenticated Rails integration, and a Postman workflow for distinguishing request, validation, and storage failures.
Introduction: what is a file upload?
A browser sends the selected file’s bytes to a receiving endpoint. With multipart/form-data,
each file or text field is a separate part with a field name. The receiver parses those parts,
applies its acceptance policy, and writes accepted bytes to storage. A client-supplied filename
or MIME type is a label, not proof of what those bytes contain.
Setting up a basic file upload feature
Choose one handler and run it at a time on 127.0.0.1:3000. This HTML form sends a file under
the field name myfile:
<form action="http://127.0.0.1:3000/upload" method="post" enctype="multipart/form-data">
<label for="myfile">Image</label>
<input id="myfile" type="file" name="myfile" />
<button type="submit">Upload File</button>
</form>
The attribute enctype="multipart/form-data" is essential because it instructs the browser to send
file data along with standard form fields.
The Node, Flask, and PHP programs below are local upload-handler demonstrations. Bind them to loopback and keep their storage outside any static/document root. They do not implement login: put authentication, authorization, CSRF protection, per-user quotas, and rate limits in front of them before deployment. The Rails fragment integrates with an existing authenticated application.
Use Bash and separately installed Node.js and Corepack; see the Yarn installation guide. The tested versions are Node.js 24.21.0 and Yarn 4.18.0; these are reproduction pins, not intrinsic minimum versions. Work outside an existing JavaScript project so its package-manager settings cannot change the install. Paste this block in your shell; its subshell keeps your working directory and shell options intact on failure:
(
for tool in node corepack; do
command -v "$tool" >/dev/null || { printf 'Missing %s\n' "$tool" >&2; exit 1; }
done
[ -z "${YARN_RC_FILENAME:-}" ] || { printf 'Use the default Yarn configuration filename\n' >&2; exit 1; }
node --input-type=module -e '
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
let directory = process.cwd();
for (;;) {
if (["package.json", "yarn.lock", ".yarnrc.yml", ".pnp.cjs"].some(name => existsSync(join(directory, name)))) {
throw new Error("Choose a directory outside an existing JavaScript project");
}
const parent = dirname(directory);
if (parent === directory) break;
directory = parent;
}
' &&
mkdir upload-node && cd upload-node &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.18.0"}' > package.json &&
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml &&
corepack yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0 &&
corepack yarn node --input-type=module -e "await import('express'); await import('express-fileupload'); await import('file-type')"
)
The block creates upload-node but leaves your caller in its original directory. If installation
fails, keep that directory and its files. After fixing connectivity or the reported install problem,
retry the explicit dependency list; a bare yarn install can succeed with an empty manifest after an
early resolution failure:
(
cd upload-node &&
corepack yarn add express@5.2.1 express-fileupload@1.5.2 file-type@22.1.0 &&
corepack yarn node --input-type=module -e "await import('express'); await import('express-fileupload'); await import('file-type')"
)
Save as upload-node/upload.ts. The parser keeps file data in memory, with up to two file buffers
per request. The handler requires a declared body length, allows two active HTTP responses, and
generates storage paths on the server. Its HTTP counter releases on response closure; it is not a
bound on asynchronous writes still finishing after a client disconnect.
import { randomUUID } from 'node:crypto'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import express from 'express'
import fileUpload from 'express-fileupload'
import { fileTypeFromBuffer } from 'file-type'
const app = express()
const maxBytes = 5 * 1024 * 1024
const root = join(process.cwd(), 'private-uploads')
await mkdir(root, { recursive: true, mode: 0o700 })
let active = 0
const parse = fileUpload({
// Busboy signals its limit at the threshold; the extra byte keeps maxBytes inclusive.
limits: { fileSize: maxBytes + 1, files: 2, fields: 0, parts: 3 },
abortOnLimit: true,
useTempFiles: false,
uploadTimeout: 10_000,
})
app.post('/upload', (req, res, next) => {
const length = req.get('content-length') ?? ''
if (!/^[1-9][0-9]*$/.test(length)) return res.status(411).send('Content length required')
if (Number(length) > 6 * 1024 * 1024) return res.status(413).send('Request too large')
if (active >= 2) return res.status(503).send('Busy')
active += 1
res.once('close', () => { active -= 1 })
next()
}, parse, async (req, res) => {
const files = req.files
const file = files?.myfile
if (!files || Object.keys(files).length !== 1 || !file || Array.isArray(file)) {
return res.status(400).send('Send exactly one myfile')
}
if (file.truncated || file.size < 1 || file.size > maxBytes) {
return res.status(413).send('Invalid file size')
}
let type
try {
type = await fileTypeFromBuffer(file.data.subarray(0, 4100))
} catch {
return res.status(400).send('Unrecognized image')
}
const extensions = new Map([['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/gif', 'gif']])
const extension = extensions.get(type?.mime)
if (!extension) return res.status(400).send('Unrecognized image')
const directory = join(root, randomUUID())
let created = false
try {
await mkdir(directory, { mode: 0o700 })
created = true
await writeFile(join(directory, 'image.' + extension), file.data, { flag: 'wx', mode: 0o600 })
if (res.destroyed) {
await rm(directory, { recursive: true, force: true })
return
}
res.status(201).send('File uploaded successfully')
} catch {
if (created) await rm(directory, { recursive: true, force: true })
throw new Error('Storage failed')
}
})
app.use((_error, _req, res, _next) => {
if (!res.headersSent) res.status(500).send('Upload failed')
})
const server = app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1', (error) => {
if (error) {
console.error('Upload server could not start')
process.exitCode = 1
}
})
server.requestTimeout = 15_000
server.headersTimeout = 10_000
Start the server from the same caller where you ran setup:
(
cd upload-node && corepack yarn node upload.ts
)
Leave that terminal open and stop the server with Ctrl+C when finished. Save the form as form.html and open it in your browser, or use the
Postman steps below. Each accepted request returns 201 and creates a new private directory containing
image.jpg, image.png, or image.gif; the submitted filename is never used as a storage path.
express-fileupload provides multipart
parsing; it is not inherently safer than another maintained parser.
file-type identifies signatures, not file safety.
A PNG containing appended script bytes can still be identified as PNG. Private storage and generated
extensions prevent the handler from creating a web-executable file under a client-supplied name;
safe public image delivery needs a separate decoding/re-encoding and scanning policy.
Handling file uploads in different frameworks
Python (Flask)
The tested Python runtime is 3.12.3, with Flask 3.1.3 and Pillow 12.3.0. Python’s venv module and
pip must be installed. In a fresh working directory, use a separate project and avoid activating its
venv in your caller:
(
command -v python3 >/dev/null || { printf 'Missing python3\n' >&2; exit 1; }
mkdir upload-flask && cd upload-flask &&
python3 -m venv .venv &&
.venv/bin/python -m pip install Flask==3.1.3 Pillow==12.3.0
)
Save as upload-flask/app.py. The complete handler creates private storage, checks an actual image parser,
and uses only a server-chosen extension. The request limit includes multipart overhead; the file
limit is checked separately.
from io import BytesIO
from pathlib import Path
import os
import uuid
import warnings
from flask import Flask, abort, request
from PIL import Image, UnidentifiedImageError
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 6 * 1024 * 1024
# Keep Flask's default form-memory limit; Werkzeug also applies it to parser buffers.
app.config['MAX_FORM_PARTS'] = 2
root = Path.cwd() / 'private-uploads'
root.mkdir(mode=0o700, exist_ok=True)
Image.MAX_IMAGE_PIXELS = 20_000_000
extensions = {'JPEG': 'jpg', 'PNG': 'png', 'GIF': 'gif'}
@app.post('/upload')
def upload():
if set(request.files) != {'myfile'} or len(request.files.getlist('myfile')) != 1:
abort(400)
data = request.files['myfile'].stream.read(5 * 1024 * 1024 + 1)
if not data or len(data) > 5 * 1024 * 1024:
abort(413)
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(BytesIO(data), formats=['JPEG', 'PNG', 'GIF']) as image:
extension = extensions.get(image.format)
image.verify()
except (UnidentifiedImageError, OSError, SyntaxError, ValueError,
Image.DecompressionBombError, Image.DecompressionBombWarning):
abort(400)
if extension is None:
abort(400)
destination = root / (uuid.uuid4().hex + '.' + extension)
created = False
try:
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
created = True
with os.fdopen(descriptor, 'wb') as output:
output.write(data)
except OSError:
if created:
destination.unlink(missing_ok=True)
abort(500)
return 'File uploaded successfully.', 201
@app.errorhandler(500)
def storage_error(_error):
return 'Upload failed.', 500
if __name__ == '__main__':
app.run(host='127.0.0.1', port=int(os.environ.get('PORT', '3000')), debug=False)
Start it from the original caller and stop it with Ctrl+C:
(
cd upload-flask && .venv/bin/python app.py
)
If pip failed after creating the venv, keep it and retry from the original caller:
(
cd upload-flask && .venv/bin/python -m pip install Flask==3.1.3 Pillow==12.3.0
)
The built-in server is for local development. Use the same form and myfile field as Node.
Pillow verification
does not re-encode the image or remove appended content. Store originals privately; isolate image
processing and impose CPU/memory limits before accepting untrusted public traffic.
PHP
This example was tested with PHP 8.4.26 and its FileInfo extension. Create the directories first:
(
command -v php >/dev/null || { printf 'Missing php\n' >&2; exit 1; }
php -r 'exit(extension_loaded("fileinfo") ? 0 : 1);' &&
mkdir upload-php && cd upload-php &&
mkdir public && mkdir -m 700 private-uploads
)
Save the handler as upload-php/public/upload.php. Start it from the original caller and stop it with Ctrl+C:
(
cd upload-php &&
php -d display_errors=0 -d upload_max_filesize=5M -d post_max_size=6M -d max_file_uploads=2 -S 127.0.0.1:3000 -t public
)
Set the form action or Postman URL to
http://127.0.0.1:3000/upload.php. PHP must own the private directory and be able to write there.
PHP’s upload contract includes an
upload-error code and a server temporary path. Check those before inspecting content.
Never preserve the submitted extension: a file named picture.php can contain image bytes.
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
function rejectUpload(int $status): never {
http_response_code($status);
exit('Upload rejected.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405);
}
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 6 * 1024 * 1024) {
rejectUpload(413);
}
$file = $_FILES['myfile'] ?? null;
if (count($_FILES) !== 1 || !is_array($file) ||
!isset($file['error']) || !is_int($file['error'])) {
rejectUpload(400);
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) {
rejectUpload(413);
}
if ($file['error'] !== UPLOAD_ERR_OK || !is_string($file['tmp_name'] ?? null) ||
!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400);
}
$directory = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false || $size < 1 || $size > 5 * 1024 * 1024) {
rejectUpload(413);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
if (!is_string($mime) || !isset($extensions[$mime])) {
rejectUpload(400);
}
$root = dirname(__DIR__) . '/private-uploads';
if (!is_dir($root) || !is_writable($root)) {
throw new RuntimeException('Storage unavailable');
}
$candidate = $root . '/' . bin2hex(random_bytes(16));
if (!mkdir($candidate, 0700)) {
throw new RuntimeException('Storage unavailable');
}
$directory = $candidate;
$destination = $directory . '/image.' . $extensions[$mime];
if (!move_uploaded_file($file['tmp_name'], $destination) || !chmod($destination, 0600)) {
throw new RuntimeException('Storage unavailable');
}
http_response_code(201);
echo 'File uploaded successfully.';
} catch (Throwable $error) {
if ($directory !== null) {
if (isset($destination) && is_file($destination)) {
unlink($destination);
}
rmdir($directory);
}
error_log('Upload storage failed');
http_response_code(500);
echo 'Upload failed.';
}
Disable display_errors for an HTTP deployment so PHP filesystem warnings cannot expose paths.
FileInfo is content sniffing, not a malware scan or full image decoder. PHP cleans its temporary
upload files when the request ends; the handler moves only accepted files into private storage.
The random private subdirectory prevents
move_uploaded_file() overwrites
of other accepted uploads.
Ruby on Rails
The attached, content_type, and size validators come from
active_storage_validations, not
Rails itself. This example uses Rails 8.1.3.1, the tested Ruby 3.3.10 runtime, and version 4.1.1 of that gem.
Add it to your existing application’s Gemfile and run bundle install:
gem 'active_storage_validations', '4.1.1'
Keep your application’s resolved dependencies in Gemfile.lock.
Use an existing authenticated application with Active Storage installed. Configure a private
Disk service or private object storage using the
Active Storage guide.
For a local Disk service, place its root outside public and give that directory mode 0700.
The content spoofing option below additionally needs the UNIX file executable. The tested helpers
were file 5.46 and 5.48; their PNG classification differs, as described in the acceptance table below.
This host used Rack 3.2.7. Keep the helper installed separately from the Ruby gems.
# app/models/user.rb
class User < ApplicationRecord
has_one_attached :myfile
validates :myfile,
content_type: { in: ['image/png', 'image/jpeg', 'image/gif'], spoofing_protection: true },
size: { between: 1..5.megabytes }
end
In an application whose authentication layer already provides current_user, use this
controller. Do not select a user from params[:user_id]. It accepts only a new multipart
upload, not a client-provided Active Storage signed blob ID.
# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
before_action :require_upload_user
protect_from_forgery with: :exception
def new
render :new
end
def create
file = params.require(:user).permit(:myfile)[:myfile]
unless file.is_a?(ActionDispatch::Http::UploadedFile)
return render plain: 'Send a file.', status: :bad_request
end
user = current_user
if user.update(myfile: file)
render plain: 'File uploaded successfully.', status: :created
else
reasons = user.errors.details[:myfile].map { |detail| detail[:error].to_s }.uniq
Rails.logger.warn('Upload validation rejected: ' + reasons.join(', '))
render plain: 'File rejected.', status: :unprocessable_entity
end
end
private
def require_upload_user
head :unauthorized unless current_user
end
end
Add these routes to config/routes.rb:
get '/upload', to: 'uploads#new'
post '/upload', to: 'uploads#create'
Save this view as app/views/uploads/new.html.erb. Rails emits the CSRF token and the nested
user[myfile] field name expected by the controller:
<%= form_with scope: :user, url: '/upload', multipart: true do |form| %>
<%= form.label :myfile, 'Image' %>
<%= form.file_field :myfile, accept: 'image/png,image/jpeg,image/gif', required: true %>
<%= form.submit 'Upload file' %>
<% end %>
Start the existing app from its project directory:
bin/rails server -b 127.0.0.1 -p 3000
Sign in through its normal login and visit /upload. Stop the local server with Ctrl+C.
current_user must return the signed-in
User; the feature keeps attachments optional so creating a user or editing their name still works.
Keep the storage service private and configure authenticated download controllers. Default Active Storage signed URLs are not per-user authorization. Enforce a request-body limit at your web server before Rails parses multipart data. Model validation happens after parsing and cannot supply that network resource limit. These examples do not enable direct uploads; unused blobs from other flows need a separate retention policy.
Common file upload issues and solutions
Why won't my file upload?
Start with the response, then check which stage refused the request. These local handlers all require a nonempty file of at most 5 MiB (5,242,880 bytes), including exactly 5 MiB. Node, Flask, and PHP also cap the declared request body at 6 MiB, including multipart overhead. Rails needs that body limit enforced by your existing web server before model validation.
| Symptom | What to check in these examples |
|---|---|
| 404 or 405 | Confirm the URL and POST method. PHP receives /upload.php; the other examples receive /upload. |
| 400 | Check the multipart encoding, expected field, and the content-recognition policy below. Flask also returns 400 for its pixel ceiling or an image-verification failure. Node’s parser can leave no file after an upload inactivity timeout, producing the same 400 as a missing field. |
| 411 in Node | Send a known body length. Streaming chunked requests are refused by this demonstration. |
| 413 | Check the file and request byte limits. Node, Flask, and PHP also use 413 for an empty file. Flask’s parser can return 413 for too many parts or its form-memory limit. |
| 401 in Rails | Sign in through the host application. An upload cannot choose a user by ID. |
| 422 in Rails | Supply the session’s CSRF token and the nested user[myfile] file field. A valid CSRF request can still fail content or size validation, including an empty file. |
| 500 | Check storage permissions. The tested Rails host also returned 500 at Rack’s file/part limits before the handler ran. |
| 503 in Node | Two HTTP responses are already active. Wait for them to finish, then retry. |
A small PNG can exceed a pixel limit while remaining below the byte limit. Flask refuses images
above 20,000,000 pixels; exactly that many pixels pass its dimension check. Its verify() call
checks file structure without fully decoding pixels. Node’s signature lookup, PHP’s FileInfo, and
Rails’ MIME spoofing check likewise do not establish that every image can decode, that metadata is
removed, or that a file is free of malware.
The following recognition boundaries were observed with the printed dependency pins. “Accepted” means stored as private original bytes, not approved for public delivery:
| Input | Node | Flask | PHP 8.4.26 FileInfo | Rails with file 5.46 / 5.48 |
|---|---|---|---|---|
| Ordinary PNG, metadata-bearing JPEG, GIF | Accepted | Accepted | Accepted | Accepted / accepted |
| Valid MPF/MPO multi-picture JPEG | Accepted as JPEG | 400: Pillow reports MPO, outside the returned-format allowlist | Accepted as JPEG | Accepted / accepted |
| Compact APNG | 400: recognized as APNG | Accepted as PNG | Accepted as PNG | Accepted / 422: MIME mismatch |
| APNG with 12 KB of metadata before its animation marker | Accepted as PNG | Accepted as PNG | Accepted as PNG | Accepted / 422: MIME mismatch |
| APNG with 40 KB or 90 KB before that marker | Accepted as PNG | Accepted as PNG | Accepted as PNG | Accepted / accepted |
Node samples only the first 4,100 bytes. file 5.48’s
PNG magic rule
searches 16,384 bytes from offset 33 for the animation marker. Moving a marker past a search
window changes recognition without changing whether the image is animated. These handlers do not
enforce a general animation ban. Flask’s MPO refusal is also a policy boundary, not proof of a
corrupt JPEG; see Pillow’s MPO support.
Multipart cardinality has a separate trap: parsed fields are not a count of all submitted parts.
Node examines at most two file parts and three total parts, discards text fields, and refuses
multiple files visible in its parsed map. A foreign file beyond the parser’s part limit can be
omitted while the visible myfile is stored with 201. Flask allows two total parts, counts text
parts too, and returns 413 above that limit; duplicate visible files return 400. Its default
form-memory limit remains 500,000 bytes, separate from the file and body limits.
PHP’s printed max_file_uploads=2 processes up to two file parts. Duplicate scalar myfile parts
collapse to the last processed one, and later file parts can be ignored. Rails similarly takes the
last parsed user[myfile] and ignores other fields permitted out of the controller’s input. In the
tested Rack host, 127 file parts and 4,095 total parts were accepted; reaching 128 file parts or
4,096 total parts triggered a parser exception. These bounded parsed-field policies do not promise
refusal of every raw duplicate or extra file. Send one file and no unnecessary fields for this task.
Solutions
Use a known, decodable image first, then change one input at a time: the field name, body size, content, or destination permission. Node additionally has a ten-second upload inactivity timer and an HTTP request timeout setting of 15 seconds. A slow request can fail below the size ceiling; those settings do not guarantee an exact wall-clock response deadline.
For Rails validation failures, the printed controller logs only reason codes from
errors.details,
under the prefix “Upload validation rejected”. Look there after a 422 to distinguish content-type,
spoofing, and size refusals. Do not log submitted bytes, filenames, session cookies, or tokens.
Inspect Rails’ state before retrying a storage error. A validation refusal keeps the previous attachment reference and bytes. A later write failure is different: Active Storage saves the reference before its after-commit upload. In the tested Disk service with the inline job adapter, a forced write failure returned 500 with a new committed reference pointing to a missing file. The prior blob and its bytes remained, but were no longer the user’s current attachment. Read the user through a fresh query and check that blob’s actual file or download, then inspect the prior blob and your job queue. A 500 does not establish rollback; fix the storage cause and reconcile the reference before another update.
Testing file upload APIs with Postman
Use the running Node or Flask demonstration first. These steps were tested in Postman 12.31.7’s
Linux desktop client; PHP uses the same field with its /upload.php URL. For Rails, use the
application’s generated form unless you also supply its authenticated session and CSRF token.
- Create a POST request to
http://127.0.0.1:3000/upload. - Open Body and select form-data.
- Add
myfile, choose File as its type, and select a local image with the operating system’s file picker. - Leave the multipart Content-Type header to Postman so it includes the matching boundary.
- Click Send. Expect 201 and one new stored file, then compare its bytes with the image you selected.
- Change only the field to
wrongfieldand send again. Node, Flask, and PHP return 400 without adding a stored file.
For a command-line check, place your chosen valid PNG at sample.png in the caller’s directory.
Use cURL with --fail-with-body support; this command was tested with cURL 8.5.0:
curl --fail-with-body --silent --show-error -F "myfile=@./sample.png" http://127.0.0.1:3000/upload
A successful response says the handler completed its write. Also inspect private-uploads:
compare the complete stored bytes and open the stored image with an independent decoder. A response
alone cannot tell you that you inspected the right output. The demonstrations retain accepted
originals; stop the server and remove only your own fixture uploads when finished.
Security best practices for file uploads
Keep originals outside static/document roots. The Node and PHP examples use a new server-named
private subdirectory for each upload; Flask uses exclusive creation of a random server-named file.
Repeated accepted requests retain separate outputs. None of them uses the submitted filename as a
storage path, so changing a photo’s name to photo.php does not create a PHP script under that name.
Recognition, decoding, malware scanning, and publication are different decisions. A recognized header can survive damage late in an image; the Node and PHP examples can store such a file. Even Pillow verification is not a full decode. Before public delivery, use an isolated processing stage that fully decodes supported content, applies resource limits, produces the intended output, and follows your scanning policy. Keep the original private until those steps complete. No scanner or publication stage is implemented by these local handlers.
Advanced file upload techniques
If your actual problem is resuming interrupted transfers or showing upload progress, continue with optimizing file uploads in web applications. It covers a different client/gateway contract; it is not a drop-in client for these multipart endpoints. For a session-backed Laravel and Vue integration, use the Laravel upload guide.
Conclusion and further resources
For a failed multipart upload, keep a minimal successful request and compare one changed condition against it. The Flask upload guide, PHP upload documentation, and Active Storage guide explain the native contracts behind these examples.
