Store images as files, not base64
When the editor has nowhere to send an image, it writes the picture into the HTML as base64 text. That is fine for a demo and a problem in production: one phone photo adds megabytes to every saved post, fills database columns, slows every page that loads the post, and is copied again into every revision and backup. This guide sets up the alternative from start to finish: an image folder on your server, uploads into it, a gallery that browses it, base64 turned off, a server-side check, and a migration for posts that already contain base64.
What base64 costs
| Image | Stored | In the saved HTML |
|---|---|---|
| A 4000 x 3000 photo (JPEG), embedded as-is | 4.3 MB file | 5.8 MB of text in the saved post |
| The same photo, uploaded | 4.3 MB file on disk | about 60 characters: <img src="/imageuploads/…jpg"> |
| The same photo, embedded by core 2.6.5 without a handler | shrunk to 1600 x 1200, 129 KB | 172 KB of text |
Base64 is a third larger than the file it encodes, and it is stored inside the document, so the cost is paid on every read of the post, not once.
What the editor does without an upload handler
Core 2.6.5 and later caps and shrinks base64 by default:
- A photo is resized in the browser (longest side 1600 px, then smaller if needed) and re-encoded before it is embedded. A typical phone photo ends up well under the cap.
- Anything still larger than 200 KB is refused with a message that points to the upload handler.
- GIF (animation) and SVG are never re-encoded; a small image is embedded unchanged.
- The first time a page embeds base64, the browser console shows a warning, so a missing handler is noticed during development.
- When an upload handler is configured and the upload fails, the image is removed and the user is told. Older cores kept the full-size base64 preview instead, which is how base64 reached databases on sites that had a handler.
// Core 2.6.5 defaults - you do not need to write these, they are shown so you can change them.
var config = {};
config.allowBase64Images = true; // false = never embed; an upload handler is then required
config.base64ImageMaxSize = 204800; // bytes after shrinking (200 KB); 0 = no cap
config.base64ImageShrink = true; // resize photos in the browser before embedding
config.base64ImageMaxDimension = 1600; // longest side, in pixels, of a shrunk photobase64ImageMaxSize = 0 together with base64ImageShrink = false restores the old behaviour exactly, if you depend on it.
Step 1: create the image folder
Everything below uses one folder, imageuploads, served at the URL /imageuploads. Uploads go into it, the gallery browses it, and its sub-folders are the gallery's folders:
imageuploads/ upload + gallery root
2026…3f2c.jpg uploaded at the root
products/ made with New folder
2026…9a1e.png
migrated/ written by step 6| Platform | Folder | Needs write access | Full walkthrough |
|---|---|---|---|
| ASP.NET Core | wwwroot/imageuploads | the app pool / service account | ASP.NET Core article |
| ASP.NET (Web Forms, MVC 5) | ~/imageuploads | IIS AppPool\<your pool> | ASP.NET (Web Forms, MVC 5) article |
| PHP | imageuploads/ next to the scripts | www-data (Apache/nginx user) | PHP article |
Give the web server's account write access to that folder only, not to the rest of the site. Then make sure nothing in it can ever run as code, and that browsers treat every file as exactly the type it claims to be:
<!-- web.config inside imageuploads/ (IIS): serve files, never run them -->
<configuration>
<system.webServer>
<!-- Read only: no script or executable handler runs in this folder -->
<handlers accessPolicy="Read" />
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
<add name="Content-Security-Policy" value="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration># .htaccess inside imageuploads/ (Apache): serve files, never run them
# (a deny rule works under mod_php AND PHP-FPM; "php_flag engine off" breaks FPM hosts with a 500)
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh|aspx?|ashx)$">
Require all denied
</FilesMatch>
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
</IfModule>
# nginx equivalent, in the server block:
# location /imageuploads/ { location ~ \.php$ { return 403; } add_header X-Content-Type-Options nosniff; }// ASP.NET Core: the same two headers on everything served from /imageuploads.
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
if (ctx.Context.Request.Path.StartsWithSegments("/imageuploads"))
{
ctx.Context.Response.Headers["X-Content-Type-Options"] = "nosniff";
ctx.Context.Response.Headers["Content-Security-Policy"] =
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox";
}
}
});Step 2: add the upload and gallery endpoints
Each platform article contains complete, tested server code for three requests. Copy the one for your platform; the rest of this guide assumes these URLs:
| Platform | Upload (file in, URL out) | Gallery (list, New folder) |
|---|---|---|
| ASP.NET Core | /rte/upload | /rte/gallery |
| ASP.NET (Web Forms, MVC 5) | /RTEUpload.ashx | /RTEGallery.ashx |
| PHP | /rte-upload.php | /rte-gallery.php |
The upload endpoint answers READY:<url> or ERROR:<message>. It must check the extension andthe file's first bytes, refuse SVG, pick its own file name rather than trusting the uploaded one, and accept a folder parameter only after checking it stays inside imageuploads. The article code does all of this. The JSON the gallery expects is described in Image gallery path.
Step 3: connect the editor, and turn base64 off
Include rte-upload.jsfrom your platform's article (it defines rte_file_upload_handler, used for inserts, paste, drag-and-drop and the image's Replace button), plus the article's richTextBoxUploadFileso the gallery's Upload button puts files in the folder being browsed. Then:
var config = {};
// 1. Store images as files: every insert, paste, drop and Replace goes through this.
// (rte-upload.js from the article for your platform defines it; see step 3.)
// window.rte_file_upload_handler = function (file, callback) { ... };
// 2. Never embed base64, even if the handler is missing or fails to load.
config.allowBase64Images = false;
// 3. Refuse huge files before they are uploaded at all.
config.maxUploadFileSize = 5 * 1024 * 1024; // 5 MB
// 4. Browse the upload folder from the Insert Gallery dialog.
config.galleryEndpoint = "/rte/gallery"; // Web Forms: "/RTEGallery.ashx", PHP: "/rte-gallery.php"
var editor = new RichTextEditor("#div_editor", config);With allowBase64Images = false, a page whose upload script failed to load refuses images instead of quietly embedding them. The messages can go to your own UI:
// Show the editor's messages in your own UI instead of alert().
config.oncustomdialog = function (state, name, message) {
// name: "base64disabled", "base64toobig", "uploadtoobig" or "uploadfailed"
if (!/^(base64disabled|base64toobig|uploadtoobig|uploadfailed)$/.test(name)) return;
showMyToast(message);
state.returnValue = true; // handled - the editor shows nothing itself
};On a core older than 2.6.5:
// Before core 2.6.5 there is no allowBase64Images. Two settings get you most of the way:
var config = {};
config.maxUploadFileSize = 5 * 1024 * 1024; // refuse huge files outright (core 2.6.1 and later)
config.maxHTMLLength = 60000; // cap the whole post below your column size
// ...and configure an upload handler (step 3), so nothing is embedded in the first place.
// A FAILED upload still leaves the full-size base64 preview in older cores: check on the server (step 5).Step 4: check it in the browser
- Insert a large photo with the Insert Image dialog's Upload option. In the HTML view, its
srcshould be/imageuploads/…, neverdata:. - Paste an image from the clipboard and drag one in: same result.
- Open Insert Gallery, create a folder, upload into it, and check the file appears in that folder on disk.
- Stop the upload endpoint (or point the handler at a wrong URL) and insert again: the user sees an error and no image is left in the text.
- Open
/imageuploads/<a file>in the browser and check the response headers includeX-Content-Type-Options: nosniff.
Step 5: refuse base64 on the server
Browser settings protect honest users; anyone can post HTML straight to your save action. Check before you store:
using System.Text.RegularExpressions;
static class RteImages
{
static readonly Regex AnyDataImage =
new(@"src\s*=\s*[""']?\s*data:image/", RegexOptions.Compiled | RegexOptions.IgnoreCase);
// True when saved HTML still carries a base64 image.
public static bool HasBase64Image(string html) => AnyDataImage.IsMatch(html ?? "");
}
// In the action that saves a post:
if (RteImages.HasBase64Image(model.Body))
return BadRequest("Images must be uploaded, not pasted into the text.");<?php
// True when saved HTML still carries a base64 image.
function rte_has_base64_image($html) {
return preg_match('#src\s*=\s*["\']?\s*data:image/#i', $html) === 1;
}
// In the script that saves a post:
if (rte_has_base64_image($_POST["body"])) {
http_response_code(400);
exit("Images must be uploaded, not pasted into the text.");
}Both catch quoted and unquoted src attributes.
Step 6: move existing base64 images into files
Posts saved before the change still carry their images inline. This one-off job writes each image to imageuploads/migrated and replaces the src with its URL. Back up the table first, and run it once on a copy to check the result.
using System.Security.Cryptography;
using System.Text.RegularExpressions;
static class RteImageMigration
{
static readonly Dictionary<string, string> Types = new(StringComparer.OrdinalIgnoreCase)
{ ["png"] = "png", ["jpeg"] = "jpg", ["jpg"] = "jpg", ["gif"] = "gif", ["webp"] = "webp", ["bmp"] = "bmp" };
static readonly Regex DataImage = new(
@"(src\s*=\s*[""'])data:image/([a-zA-Z]+);base64,([A-Za-z0-9+/=\s]+)([""'])", RegexOptions.Compiled);
// Moves every base64 image in one post into a file and returns the rewritten HTML.
// physicalDir e.g. Path.Combine(env.WebRootPath, "imageuploads", "migrated")
// urlPrefix e.g. "/imageuploads/migrated"
// The file name is the SHA-1 of the image bytes, so a picture shared by many posts is
// written once, and running the migration twice changes nothing.
public static string MigrateBase64Images(string html, string physicalDir, string urlPrefix)
{
Directory.CreateDirectory(physicalDir);
return DataImage.Replace(html, m =>
{
if (!Types.TryGetValue(m.Groups[2].Value, out var ext)) return m.Value; // e.g. SVG: left alone
byte[] bytes;
try { bytes = Convert.FromBase64String(Regex.Replace(m.Groups[3].Value, @"\s+", "")); }
catch (FormatException) { return m.Value; }
if (bytes.Length == 0) return m.Value;
string name = Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant() + "." + ext;
string path = Path.Combine(physicalDir, name);
if (!File.Exists(path)) File.WriteAllBytes(path, bytes);
return m.Groups[1].Value + urlPrefix.TrimEnd('/') + "/" + name + m.Groups[4].Value;
});
}
}
// One-off job, e.g. with EF Core:
foreach (var post in db.Posts.Where(p => p.Body.Contains("data:image/")))
post.Body = RteImageMigration.MigrateBase64Images(post.Body, migratedDir, "/imageuploads/migrated");
db.SaveChanges();<?php
// Moves every base64 image in one post into a file and returns the rewritten HTML.
// $dir e.g. __DIR__ . "/imageuploads/migrated" $url e.g. "/imageuploads/migrated"
// The file name is the SHA-1 of the image bytes, so a picture shared by many posts is
// written once, and running the migration twice changes nothing. Works on PHP 5.6+.
function rte_migrate_base64_images($html, $dir, $url) {
$types = array("png" => "png", "jpeg" => "jpg", "jpg" => "jpg", "gif" => "gif", "webp" => "webp", "bmp" => "bmp");
if (!is_dir($dir)) mkdir($dir, 0755, true);
return preg_replace_callback(
'#(src\s*=\s*["\'])data:image/([a-zA-Z]+);base64,([A-Za-z0-9+/=\s]+)(["\'])#',
function ($m) use ($types, $dir, $url) {
$type = strtolower($m[2]);
if (!isset($types[$type])) return $m[0]; // e.g. SVG: left alone
$bytes = base64_decode(preg_replace('/\s+/', '', $m[3]), true);
if ($bytes === false || $bytes === "") return $m[0];
$name = sha1($bytes) . "." . $types[$type];
$path = $dir . DIRECTORY_SEPARATOR . $name;
if (!file_exists($path)) file_put_contents($path, $bytes);
return $m[1] . $url . "/" . $name . $m[4];
},
$html);
}
// One-off job, e.g. with PDO:
$rows = $pdo->query("SELECT id, body FROM posts WHERE body LIKE '%data:image/%'");
$save = $pdo->prepare("UPDATE posts SET body = ? WHERE id = ?");
foreach ($rows as $row) {
$save->execute(array(rte_migrate_base64_images($row["body"], __DIR__ . "/imageuploads/migrated",
"/imageuploads/migrated"), $row["id"]));
}- Files are named by content hash: a picture used in 500 posts is written once, and a second run changes nothing.
- Only PNG, JPEG, GIF, WebP and BMP are extracted. SVG is left in place for you to review, because it can carry script.
- Other attributes on the image (
alt,style,width) are kept. - An unquoted
src=data:…is not rewritten; the step 5 check still finds it.
Settings reference
| Setting | Default | Meaning |
|---|---|---|
allowBase64Images | true | false: never embed; images need an upload handler. Core 2.6.5+. |
base64ImageMaxSize | 204800 | Largest embedded image in bytes, after shrinking. 0 = no cap. Core 2.6.5+. |
base64ImageShrink | true | Resize and re-encode photos before embedding. Core 2.6.5+. |
base64ImageMaxDimension | 1600 | Longest side in pixels of a shrunk photo. Core 2.6.5+. |
maxUploadFileSize | 0 | Largest file the editor will insert or upload, in bytes; checked before anything else. |
galleryEndpoint | none | URL of your gallery endpoint; see Image gallery path. |
Related: Upload files (handlers, progress, retries, cancel) and Image gallery path (the gallery JSON contract).