Rails file uploads with Active Storage
Active Storage is the answer to the rails file upload question and it has been since Rails 5.2, so
the interesting part is not whether to use it. The interesting part is that the default path through
it has one real hole, and the hole is not where the tutorials point. Everybody knows to validate the
content type. Almost nobody knows that params[:avatar].content_type is not the content type Active
Storage stores, that a validation written against the parameter checks a string the attacker chose,
and that the value Active Storage does store still falls back to that string in one specific case.
Everything below was reproduced against activestorage 8.1.3.1 on Ruby 4.0.5, with marcel 1.2.1 and ImageMagick 7.1.2-30. Framework source is quoted with its file.
The two declarations and the three tables under them
has_one_attached and has_many_attached add no column to your table. The Rails 8.1 install
migration creates three: active_storage_blobs holds the file's identity (key, filename,
content_type, byte_size, checksum, metadata), active_storage_attachments is the polymorphic join
between one of your rows and one blob, and active_storage_variant_records remembers derivatives.
The declaration expands into ordinary Active Record. From attached/model.rb:128:
has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment",
as: :record, inverse_of: :record, dependent: :destroy, strict_loading: strict_loading
has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob",
source: :blob, strict_loading: strict_loading
So has_one_attached :avatar on a User gives you avatar_attachment, avatar_blob,
with_attached_avatar, and the avatar reader, which is not either association but an
ActiveStorage::Attached::One proxy. has_many_attached :documents gives the plural forms and an
ActiveStorage::Attached::Many. The join row is unremarkable enough to print:
{"id" => 7, "name" => "avatar", "record_type" => "User", "record_id" => 4, "blob_id" => 7}
The name column is what lets one model carry an avatar and a passport scan in the same table, and
the scope -> { where(name: name) } on the association is what keeps them apart. Because
record_type stores a class name as a string, renaming the model breaks every attachment until you
update that column, which is the general hazard of
polymorphic associations in Rails showing up in framework code
rather than in yours.
Attaching, and the two times attach does not save
Attached::One#attach is eight lines, and the conditional in the middle surprises people:
def attach(attachable)
record.public_send("#{name}=", attachable)
if record.persisted? && !record.changed?
return if !record.save
end
record.public_send("#{name}")
end
Two branches never write anything. The first is a record with any other unsaved attribute: assigning
user.name = "renamed" and then calling user.avatar.attach(...) makes record.changed? true, the
save is skipped, and the attachment lives in attachment_changes until somebody calls save.
Reproduced, the row in the database still reports attached? false until the explicit save! lands.
The second branch is louder in its consequences and quieter in its output. On a clean persisted
record, attach does call save, save runs validations, and if a validation rejects the blob then
return if !record.save hands you back nil:
record.changed? before attach = false
attach returned : nil
record.errors : ["Avatar must be a PNG or JPEG, got text/html"]
row in db : false
blob rows in db : 0
bytes on disk : 0
No exception. A controller that writes @user.avatar.attach(params[:avatar]) and then redirects with
a success flash will tell the user their photo was saved, every time the validation fires. The fix is
to treat the return value as a result, or to assign with @user.avatar = params[:avatar] and let
your normal if @user.save branch do the work it already does for every other attribute.
Assigning an array is a replace, and a blank array is a delete
The has_many_attached setter is worth reading before you build an edit form, because its first line
decides more than it looks like it does:
def #{name}=(attachables)
attachables = Array(attachables).compact_blank
pending_uploads = attachment_changes["#{name}"].try(:pending_uploads)
attachment_changes["#{name}"] = if attachables.none?
ActiveStorage::Attached::Changes::DeleteMany.new("#{name}", self)
else
ActiveStorage::Attached::Changes::CreateMany.new("#{name}", self, attachables, pending_uploads: pending_uploads)
end
end
Assignment replaces. documents.attach(a) then documents.attach(b) leaves both; documents = [c]
leaves only c and purges the other two. That is the documented behaviour since Rails 7 and it is
the right one, because a form field describes the desired end state rather than an increment.
The compact_blank is the part that bites. file_field :documents, multiple: true renders a hidden
input alongside the real one:
<input name="user[documents][]" type="hidden" value="" /><input multiple="multiple" type="file" name="user[documents][]" id="user_documents" />
That hidden field exists so the parameter is present when the user picks nothing. The parameter is
then [""], compact_blank turns it into [], none? is true, and the change is DeleteMany.
Reproduced: a record with one attachment, assigned [""], came back with zero. Every edit form that
submits the whole model and does not re-select the existing files wipes them, and the spec that
posts a file and checks it arrived will never see it, because that spec always sends a file.
config.active_storage.multiple_file_field_include_hidden, set to true by load_defaults 7.0 at
railties-8.1.3.1/lib/rails/application/configuration.rb:256, is the global switch, and
include_hidden: false on the field is the local one.
Why direct uploads exist
A normal multipart POST sends the file to your application, and your application is the wrong place for it. The bytes travel over the request body, so a Puma worker is occupied for the whole upload, which on a mobile connection and a 40 megabyte video is minutes of a thread doing nothing but copying. Your proxy has an opinion about body size and will return 413 before Rails sees anything. Then the worker writes the file a second time, to disk or to S3, while still holding the request. Concurrency is the resource you are spending, and the file is not even yours yet.
A direct upload moves the transfer off that path. The browser asks your application for permission, your application returns a presigned URL scoped to one object key for a few minutes, the browser PUTs the bytes to the storage service, and the form submits a signed reference to a blob row that already exists. Your application handles two small JSON-sized requests instead of one large one, and the large one never touches it.
The cost is three round trips instead of one, a JavaScript dependency where there was none, CORS
configuration on the bucket, and a class of orphan blob that did not exist before: a blob row created
for a direct upload whose form was never submitted. Active Storage gives you the query and not the
cleanup: blob.rb:46 defines scope :unattached, -> { where.missing(:attachments) }, and the
recurring job that runs ActiveStorage::Blob.unattached.where(created_at: ..1.day.ago).find_each(&:purge_later)
is yours to write and schedule.
Direct uploads are also the answer to a deploy question rather than a scale question. The Disk
service writes under Rails.root.join("storage"), which inside a container is gone on the next
release, so an application that deploys with Kamal either mounts a
volume there or moves to a real service before the first user uploads anything.
What the browser sends to /rails/active_storage/direct_uploads
The engine mounts nine routes, two of which exist only to serve and receive files for the Disk
service, and the one that starts a direct upload is POST /rails/active_storage/direct_uploads. Its
controller is ten lines, and the interesting one is the permit list:
params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}]).to_h.symbolize_keys
Four attributes and a metadata hash, all of them from the request body. The body is built in
blob_record.js:
this.attributes = {
filename: file.name,
content_type: file.type || "application/octet-stream",
byte_size: file.size,
checksum: checksum
}
file.type is the browser's guess, and browsers guess from the extension. The checksum is a base64
MD5 computed in 2 megabyte chunks by file_checksum.js using SparkMD5, matching
ActiveStorage.checksum_implementation, which is OpenSSL::Digest::MD5 unless OpenSSL has MD5
disabled, in which case active_storage.rb:373 falls back to Digest::MD5. It travels to S3 as the
Content-MD5 header on the presigned PUT, so a body that does not match the checksum is rejected by
the service. That is an integrity check on a truncated upload, not a control on a hostile one, since
the same client chose both numbers.
create_before_direct_upload! writes the row with no file behind it:
content_type = "image/png" <- straight from the JSON request body
identified? =
analyzed? =
checksum = "LNi95GP12CquDwzsBh1rjw=="
headers_for_direct_upload = {"Content-Type" => "image/png"}
identified is blank, which is the whole mechanism of the next two sections: the row is carrying the
client's claim, and nothing has looked at the bytes because there are no bytes yet.
Configuring the S3 service
Three things have to line up, and two of them are outside your codebase. In the Gemfile, because
s3_service.rb:3 declares the dependency itself with gem "aws-sdk-s3", "~> 1.48" and the adapter
file is only required when the service is resolved:
gem "aws-sdk-s3", require: false
In config/storage.yml:
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
region: us-east-1
bucket: your_own_bucket-<%= Rails.env %>
And in config/environments/production.rb, config.active_storage.service = :amazon, which is the
name of the YAML key rather than the adapter. Service::Configurator#resolve turns the service:
value into a constant by requireing active_storage/service/s3_service and constantizing
S3Service, and a missing gem surfaces as Missing service adapter for "S3" rather than as a load
error, which sends people looking in the wrong file.
Everything in that YAML block except service and bucket is passed through untouched. The
initializer is Aws::S3::Resource.new(**options) after bucket, upload and public are removed,
so endpoint, force_path_style and the rest of the AWS SDK's options work here with no support
from Active Storage, which is how the same adapter talks to Cloudflare R2, MinIO and Backblaze. Omit
access_key_id and secret_access_key entirely on EC2 or ECS and the SDK finds the instance role.
Direct uploads need one more thing that lives in the bucket rather than the repository: a CORS rule
allowing PUT from your origin with Content-Type, Content-MD5 and Content-Disposition in
AllowedHeaders. Without it the blob row is created, the browser's PUT fails, and the form submits a
signed id pointing at an object that does not exist. Nothing in your application logs an error,
because nothing in your application was involved.
The content type the client sends is a claim
ActionDispatch::Http::UploadedFile#content_type returns the Content-Type of the multipart part,
verbatim. A file whose bytes are a PNG, uploaded with a part header saying application/zip, answers
"application/zip", because the method is a reader over what arrived.
Three common checks are all checks on that same string or on something weaker.
The accept="image/*" attribute on the input filters the operating system's file picker and nothing
else. It is a convenience for the honest user and it is absent from any request built with curl.
A controller-level params[:avatar].content_type.start_with?("image/") reads the header the client
wrote. curl -F "avatar=@shell.sh;type=image/png" sets it to whatever you like, in one flag.
A check on File.extname(params[:avatar].original_filename) reads a string the client also chose,
and original_filename has the additional property that it is the one field in the whole exchange
that has historically reached a filesystem path.
The browser is not adversarial here, it is just uninformed: file.type in the direct upload path and
the multipart header in the form path both come from the extension. Rename report.pdf to
report.png on your desktop and Chrome will declare it image/png in perfect good faith. So the
claim is wrong routinely without anybody attacking anything, which is the reason it is worth
replacing even in an application with no hostile users.
What Active Storage stores instead of the claim
Rails already replaces it, and this is the good news that almost nobody seems to have heard.
Attached::Changes::CreateOne#initialize is three lines, and the third is the whole subject:
def initialize(name, record, attachable)
@name, @record, @attachable = name, record, attachable
blob.identify_without_saving
end
identify_without_saving reads the bytes and overwrites content_type:
def identify_content_type
Marcel::MimeType.for download_identifiable_chunk, name: filename.to_s, declared_type: content_type
end
def download_identifiable_chunk
if byte_size.positive?
service.download_chunk key, 0...4.kilobytes
else
""
end
end
Marcel ranks the candidates: magic number first, then filename, extension and declared type, and it keeps the most specific. Seven attachments, reproduced end to end:
declared stored
real png, honest type "image/png" "image/png"
real png, declared zip "application/zip" "image/png"
shell script named .png "image/png" "application/x-sh"
html named .png "image/png" "text/html"
svg with script "image/svg+xml" "image/svg+xml"
png bytes named .txt "text/plain" "image/png"
random bytes, no extension "image/png" "image/png"
Row three is the one that matters. A shell script uploaded as evil.png with a declared type of
image/png is stored as application/x-sh, because the bytes said so and the bytes outrank both the
name and the header. And because this runs inside the setter, a validate block on
avatar.blob.content_type sees the corrected value before the record is ever saved.
For a direct upload the same call happens, one step later: the blob carries the client's claim from
the moment it is created until the signed id is assigned to a record, at which point CreateOne
downloads four kilobytes from S3 and rewrites the column. Reproduced by creating a blob declared
image/png and then PUTting HTML to its key: content_type read "image/png" right up to the
attach and "text/html" immediately after.
Where the sniff gives up and the claim wins
Row seven of that table is the hole. Marcel's fallback chain ends at the declared type, so bytes that match no magic number, under a filename with no informative extension, are filed as whatever the client said:
no magic, no extension, declared image/png -> image/png
no magic, no extension, declared nothing -> application/octet-stream
no magic, no extension, declared octet -> application/octet-stream
Twenty unrecognisable bytes declared image/png produced a blob whose image? is true, whose
variable? is true and whose representable? is true. Nothing in Active Storage will refuse it, and
the first .processed call hands it to ImageMagick or libvips, which is the interesting consequence:
your image pipeline is now being fed a file chosen for its bytes rather than for its format. The
security half of that, including what libvips does with a loader it flags as unfuzzed, is
the Active Storage CVE post rather than this one.
SVG is the second gap and it is a different shape. An SVG is XML, Marcel identifies it correctly, and
image/svg+xml is a genuine image type that passes blob.image?. It is also a document that can
carry <script>, so serving one inline from your own origin is stored cross-site scripting. Active
Storage already knows this: engine.rb:53 lists image/svg+xml and text/html in
content_types_to_serve_as_binary, and servable.rb:5 rewrites the response type of anything on
that list to ActiveStorage.binary_content_type, which is application/octet-stream. A second list,
content_types_allowed_inline, is the allowlist for disposition: :attachment and does not contain
SVG either. Both defaults hold until somebody edits one of those two arrays to make avatars crisp,
and that edit looks cosmetic in a diff.
The rule that falls out of both: an allowlist of content types is not optional, and it is not the
same thing as rejecting a blocklist. application/octet-stream and image/svg+xml both need a
deliberate decision.
Writing the validation
Rails ships no attachment validation. There is no validates :avatar, content_type: in the
framework, the guide does not claim one, and the reason is that Active Storage was designed to be
agnostic about what you attach. Which leaves two options.
The first is five lines with no dependency, and it works because of the ordering established above:
class User < ApplicationRecord
has_one_attached :avatar
ALLOWED = %w[image/png image/jpeg image/webp].freeze
validate do
next unless avatar.attached?
errors.add(:avatar, "must be a PNG, JPEG or WebP") unless avatar.blob.content_type.in?(ALLOWED)
errors.add(:avatar, "must be under 5 MB") if avatar.blob.byte_size > 5.megabytes
end
end
Reproduced: HTML uploaded as photo.png with a declared type of image/png failed with
"Avatar must be a PNG or JPEG, got text/html", and a real PNG declared application/zip passed.
The validation never sees the claim because the claim is gone by the time it runs.
The second is active_storage_validations, version 4.1.1 released 2026-09-02, MIT, Ruby 3.3 or newer,
30.3 million downloads, maintained by Igor Kasyanchuk. It gives you
validates :avatar, attached: true, content_type: [:png, :jpeg], size: { less_than: 5.megabytes },
dimension: { width: { in: 800..1600 } }, aspect_ratio: :landscape, plus limit: for counting
has_many_attached files, plus translated error messages and matchers for your specs.
Take the gem when you need dimensions, aspect ratio or a file count, because those require reading metadata that may not be extracted yet and the gem has already solved the ordering. Write the block when the rule is "these content types, under this size", which is most of the time, and note that the block is the honest version of a promise the gem also cannot keep: neither one can tell you a file is safe, only that its first four kilobytes look like the format you asked for.
Variants: one digest, one row, one derivative blob
A variant is a transformation, described and hashed rather than executed. avatar.variant(...) is
free and lazy:
after .variant(...) records=0 blobs=1
after .processed records=1 blobs=2
after .processed x2 records=1 blobs=2
.processed is what downloads the original, runs the transformation, uploads the result as a second
blob, and writes an ActiveStorage::VariantRecord keyed by blob_id and variation_digest. The
digest is derived from the transformations, so resize_to_limit: [100, 100] is
uyx6Kcit1Aa78Mrn7bVgZ7OZn0Y= every time and [101, 101] is cudD5bmpBGkCynPyPm1R1layn0U=. A
unique index on that pair is what makes the second call a no-op. The original is never modified: 366
bytes in, 316 byte derivative out, original still 366.
Two failure modes, and telling them apart is the point:
stored content_type = "text/plain", variable? = false
processed raised ActiveStorage::InvariableError: Can't transform blob with ID=4 and content_type=text/plain
stored content_type = "image/png", variable? = true
processed raised MiniMagick::Error: `magick ...` failed with status: 1
InvariableError is Active Storage refusing before it starts, decided by whether the content type is
in ActiveStorage.variable_content_types. MiniMagick::Error is the image library refusing after
it started, and it is what the fallback case from two sections ago produces: a blob whose stored type
is a lie the sniffer could not catch, so variable? is true and the failure moves from the framework
to a subprocess. Calling .processed in a request turns either one into a 500. Calling it in a job,
which transform_variants_later does for named variants marked preprocessed, turns it into a
failed job and a broken image tag.
The transformer that is nil
Missing libvips does not fail the boot. engine.rb:96 builds the transformer inside a begin, and
the rescue is a log line:
rescue LoadError => error
case error.message
when /libvips/
ActiveStorage.logger.warn <<~WARNING.squish
Using vips to process variants requires the libvips library.
Please install libvips using the instructions on the libvips website.
WARNING
ActiveStorage.variant_transformer is then left at nil, the application starts, every page that
does not render a variant works, and the first one that does fails in variation.rb:85 with
NoMethodError: undefined method 'new' for nil. Reproduced on this machine, which has ImageMagick
and no libvips, against the Rails 8.1 default of variant_processor = :vips.
A missing native dependency deserves a boot failure, and this one buys its warning at the price of a stack trace that names neither libvips nor the configuration. Grepping your production logs for that warning is a two minute check worth doing once.
N+1 across three tables
Rendering a list of records with their attachments is three queries per row rather than one, because the blob is two hops away. Measured over 20 users, each with one attached PNG:
plain : 41 queries
with_attached_avatar : 5 queries
41 is one for the users plus two per user. The generated scope is a plain includes, and with
ActiveStorage.track_variants true, which is the Rails 8.1 default, it preloads the variant records
and their own attachments too:
scope :"with_attached_#{name}", -> {
if ActiveStorage.track_variants
includes("#{name}_attachment": { blob: {
variant_records: { image_attachment: :blob },
preview_image_attachment: { blob: { variant_records: { image_attachment: :blob } } }
} })
else
includes("#{name}_attachment": :blob)
end
}
Five queries where a naive includes(:avatar_attachment) would have left the variant lookups to fire
per row. The general shape of the mistake, and why includes sometimes emits two queries and
sometimes one, is N+1 queries in Rails. The Active Storage
specific part is only that the scope is generated for you and is easy to not know about: it is
with_attached_ plus the attachment name, and it belongs on every index action that renders an
avatar.
The pin Rails still generates
rails new writes gem "image_processing", "~> 1.2" into your Gemfile, from
railties-8.1.3.1/lib/rails/generators/rails/app/templates/Gemfile.tt:45. That constraint cannot
resolve to 2.x, and the 1.x line stopped at 1.14.0 on 2025-02-10.
What shipped on the 2.x line since: 2.0.0 on 2026-05-20, which made mini_magick and ruby-vips soft dependencies you add yourself and blocked unfuzzed vips loaders by default, 2.0.1 and 2.0.3 closing remote code execution when loader and saver option names come from user input, and 2.1.0 on 2026-09-01.
State the exposure accurately, because the headline reads worse than the condition. Those holes are
reachable when the transformation options themselves come from a parameter, which is rare and which
you should not be doing anyway. An application that hardcodes resize_to_limit: [400, 400] is not
exposed by the option name path. The argument for moving the pin is not this quarter's CVE, it is
that a dependency with no release in nineteen months is where the next one will not be fixed.
So change the line, and change it deliberately: gem "image_processing", "~> 2.0" plus an explicit
gem "ruby-vips" or gem "mini_magick", since 2.0.0 stopped pulling either one in. The reason not
to is real and is the reason the pin is still there: a major version of the gem that stands between
your uploads and a subprocess is not a bump to make on a Friday, and Rails leaving the constraint
alone is a deliberately conservative default rather than an oversight.
The call, and what would change it
Use Active Storage. Not because it is the best file attachment library ever written, but because it is already in your Gemfile, its three tables are already in your schema, and the competition is Shrine, at 3.10.0 as of 2026-09-20 and very much alive, which is genuinely more flexible and buys that flexibility with configuration you now own.
Skip direct uploads until the files are large or frequent. The three round trips, the CORS rule, the orphan blob sweeper and the JavaScript are all real cost, and for a 200 kilobyte avatar uploaded once per account they buy nothing a bigger Puma thread pool would not. Turn them on when the median upload is measured in megabytes, or when your proxy's body limit has started returning 413.
Write the content type validation by hand unless you need dimensions. Five lines, no dependency, and
writing it yourself is how you find out that avatar.blob.content_type is not
params[:avatar].content_type, which is the single most useful thing in this post.
What would change these: a validates :avatar, content_type: in Rails itself would end the third
one, and it has been proposed often enough that it may yet arrive. A Rails release that failed the
boot on a missing libvips instead of nilling the transformer would delete a whole section. And if
Marcel ever gained a strict mode that returned application/octet-stream rather than falling back to
the declared type, the one genuine hole in the default path would close, at the cost of breaking
every application that uploads a file format nothing recognises.
The position has a cost worth naming. Hand written validation means every model that attaches
something repeats the block, and the day somebody adds has_one_attached :passport and forgets it,
nothing tells them. The gem's version of that mistake is at least visible in a diff as a missing
line next to eight present ones.
What this post does not cover
The LaunchKit boilerplate attaches one thing. Message declares has_many_attached :attachments,
and it declares it because ruby_llm 2.0.0.rc1's acts_as_message expects it:
attachment_helpers.rb:15 calls message_record.attachments.attach(attachables) when a conversation
carries a file. No view in the boilerplate renders an upload form, nothing calls variant, no
validation guards that association, config/storage.yml has the amazon: block commented out,
aws-sdk-s3 is not in the Gemfile.lock, and config/environments/production.rb line 29 says
config.active_storage.service = :local. An application built on it that accepts uploads from users
owns the service configuration and the allowlist itself, which is the honest provenance for a post
about both.
Also absent: previews of PDFs and videos, which are a different pipeline with different binaries
behind them; ActiveStorage::Blob#compose and multipart assembly; mirrored services for a migration
between providers; the proxy versus redirect decision and what each does to your CDN bill; and
signed URL expiry, which defaults to five minutes and is the setting people discover through a
support ticket about a broken image in an email.
The measurements above ran against SQLite, because every claim in them is about what Active Record
and Marcel build rather than about what a database does with it. Variants were processed with
variant_processor = :mini_magick, since libvips is not installed on this machine, which is how the
nil transformer section happened.
Comments
No comments yet. Be the first.