LaunchKit
← All posts
· 21 min read · by The LaunchKit team · 0 views

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.

#rails #active-storage

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.