Skip to main content

Encrypting Photos and Files Before Active Storage Sees Them

Follow how Inpages.me encrypts photo and file bytes in the browser, stores opaque Active Storage blobs, and reconstructs media only after local decryption.

File encryption changes the meaning of an upload. A normal Rails application receives a photograph, learns that it is a photograph, extracts metadata, and may generate a thumbnail. For a protected Inpages.me Memory, the browser must turn those bytes into ciphertext first. Active Storage receives ciphertext bytes, not an image it can inspect.

Encrypt before the first upload

The useful question is not whether the storage disk is encrypted. It is which component first sees the readable file. Server-side disk encryption protects a physical volume and storage credentials, but the application still receives plaintext. Client-side encryption moves the transformation ahead of Rails.

When a file belongs to an encrypted Memory, the browser reads its bytes into an ArrayBuffer. Inpages.me generates a fresh 96-bit initialization vector and encrypts the buffer with the Memory owner's 256-bit Master Key using AES-GCM. The uploaded binary is the IV followed by authenticated ciphertext.

plaintext = await file.arrayBuffer()
iv         = randomBytes(12)
ciphertext = AES_GCM.encrypt(masterKey, iv, plaintext)
upload     = Blob(iv + ciphertext, "application/octet-stream")

AES-GCM authentication matters as much as confidentiality. If stored bytes are truncated or modified, decryption fails rather than returning a subtly damaged photograph. The implementation uses the browser's Web Crypto encrypt() API; Rails never receives the Master Key as part of the upload.

What Active Storage receives

Active Storage still does the work it is good at: it creates a blob record, stores the binary object, attaches it to a Memory, serves it through an authorized route, and later purges it. Its role as storage plumbing does not require knowledge of the original bytes.

The difference appears at the media boundary. Rails documents that Active Storage can extract metadata and generate representations for images, video, and PDFs. Those operations require recognizable input. Inpages.me marks encrypted uploads as application/octet-stream and skips blob analysis, because passing ciphertext to an image or video analyzer would be both useless and misleading. The Active Storage guide describes the normal analysis pipeline this protected path deliberately avoids.

StageStandard fileEncrypted file
BrowserUploads original bytesUploads AES-GCM ciphertext
Content typeImage, video, audio, or document typeapplication/octet-stream
Server analysisAvailable when supportedSkipped
Storage objectReadable mediaOpaque binary
DisplayServed to the media elementFetched and decrypted locally first

The metadata that remains

Encrypting the bytes does not erase the attachment model around them. Active Storage blob records retain data needed to find and serve an object. Depending on the path, the application can still know that a Memory has attachments, when a blob was created, its byte size, storage key, filename, and generic stored content type.

The original filename is especially useful and sensitive. The browser needs a way to infer whether decrypted bytes should become an image, video, audio element, or download. A name such as scan-after-appointment.jpg can also reveal context even when the file bytes are opaque. Encryption claims must distinguish content from metadata instead of treating the attachment as one indivisible secret.

Protected inline images add another reference: encrypted rich-text HTML points to the encrypted blob's signed identifier and URL. That reference is itself inside the encrypted content, but Rails still retains the attachment row connecting the blob to the Memory. The broader encryption metadata threat model covers what those operational records mean after a server-side disclosure.

Reconstructing media in the browser

To display an encrypted photograph, Rails returns an authorized URL for the opaque blob. A Stimulus controller fetches it, asks the unlocked browser vault for the Master Key, and decrypts the bytes. The plaintext exists in browser memory rather than being uploaded again.

The controller wraps the decrypted buffer in a Blob with a content type inferred from the filename, then calls URL.createObjectURL(). That temporary URL can be assigned to an image, video, audio element, or download link just like a network URL.

Blob URLs hold browser resources until they are released. When the view disconnects, Inpages.me calls URL.revokeObjectURL(). Revocation is memory hygiene; it does not erase screenshots, downloads, browser extensions, or other endpoint access after decryption.

Replacing files during protection changes

A Memory can already contain photographs when its owner turns encryption on. Reusing the existing blob would leave plaintext behind. The browser therefore fetches each current asset, encrypts it locally, and uploads a new unattached blob. It performs the reverse transformation when moving back to Standard protection.

Rails accepts a complete source-and-replacement manifest only after checking that every current attachment has one unique staged replacement for the same Memory, target state, and asset kind. The database transaction then changes the Memory fields and attachment references together. Superseded blobs are purged later only when no other attachment still uses them.

This staging step is why upload success alone never means the Memory is protected. The technical guarantee is attached to the final commit. The full atomic Memory encryption transition explains the concurrency check, rollback behavior, and abandoned-blob cleanup.

The capabilities we give up

Opaque storage prevents server-generated thumbnails, media analysis, full-text extraction, virus inspection of plaintext, and background transformations on protected files. Re-creating any of those features would require moving the work into the trusted browser, revealing plaintext to another system, or changing the product promise.

Large files also make the browser do more work. Encryption, decryption, downloads, and temporary in-memory copies cost time and memory. A failed request can leave an unattached encrypted staging blob until cleanup. These are engineering costs created by the privacy boundary, not anomalies to hide from the interface.

The result is intentionally narrower than “Active Storage is end-to-end encrypted.” Active Storage stores and serves what it receives. Inpages.me creates the confidentiality boundary by transforming selected bytes before that handoff and decrypting them only after they return to an unlocked browser. For a reader-facing view of the same choice, see how to create a journal with photos and text.