
By DanSP
Strapi local upload provider with path organization, private files, and UUID filenames.
released February 5, 2026
npm install strapi-provider-upload-local-secure
Enhanced fork of the default Strapi local upload provider. README targets v1.0.0 behavior.
README targets v1.0.0 behavior (this packageβs current version).
data.path creates/targets Media Library folders (DB only; folders remain βvirtualβ)data.pathDir controls physical subdirectories under public/uploadspathDir is sanitized for safe filesystem pathspath is sanitized for safe Admin folder namesfileInfo as an array (one entry per file)upload.replace(...) when presentcleanupEmptyDirs)yarn add strapi-provider-upload-local-securenpm install strapi-provider-upload-local-secure --savebun add strapi-provider-upload-local-secureFor TypeScript projects - config/plugins.ts:
export default () => ({
upload: {
config: {
// Required
provider: 'strapi-provider-upload-local-secure',
// Optional (all providerOptions below are optional)
providerOptions: {
// Optional: enable debug logs (upload/delete resolution)
// Also supports env: STRAPI_PROVIDER_UPLOAD_LOCAL_PATH_DEBUG=1
debug: false,
// Optional: if `pathDir` sanitizes to an empty string, throw instead of falling back
strictPathDir: false,
// Optional: after deleting a file, attempt to remove empty parent directories under `public/uploads`
cleanupEmptyDirs: false,
// Optional: after deleting a file, attempt to delete empty Media Library folders (Admin) as well.
// Safety rules:
// - only deletes a folder if it has no child folders
// - only deletes a folder if it has no other files (excluding the file currently being deleted)
// - then repeats the same check for the parent folder (recursive upwards)
cleanupEmptyAdminFolders: false,
// Optional (default: true): if `pathDir` is not provided, use `path` as the filesystem directory
// If you want `path` to be Admin-only and never affect filesystem paths, set this to false.
usePathAsPathDir: true,
// Optional (default: "/uploads/"): marker used to extract objectPath from `file.url` during delete
uploadsUrlMarker: '/uploads/',
// Optional (default: false): save files as <uuidv4>_<hash>.ext for unique, non-guessable filenames
renameToUuid: false,
// Optional: private folder (access via Admin JWT, user JWT with matching documentId, or HMAC-signed URL)
privateEnable: false,
privateFolder: 'private',
privateTTL: 60,
privateSecret: '',
privateUserDocumentIdField: 'id',
},
},
},
});For JavaScript projects - config/plugins.js:
module.exports = ({ env }) => ({
upload: {
config: {
// Required
provider: 'strapi-provider-upload-local-secure',
// Optional (all providerOptions below are optional)
providerOptions: {
// Optional: enable debug logs (upload/delete resolution)
// Also supports env: STRAPI_PROVIDER_UPLOAD_LOCAL_PATH_DEBUG=1
debug: false,
// Optional: if `pathDir` sanitizes to an empty string, throw instead of falling back
strictPathDir: false,
// Optional: after deleting a file, attempt to remove empty parent directories under `public/uploads`
cleanupEmptyDirs: false,
// Optional: after deleting a file, attempt to delete empty Media Library folders (Admin) as well.
// Safety rules:
// - only deletes a folder if it has no child folders
// - only deletes a folder if it has no other files (excluding the file currently being deleted)
// - then repeats the same check for the parent folder (recursive upwards)
cleanupEmptyAdminFolders: false,
// Optional (default: true): if `pathDir` is not provided, use `path` as the filesystem directory
// If you want `path` to be Admin-only and never affect filesystem paths, set this to false.
usePathAsPathDir: true,
// Optional (default: "/uploads/"): marker used to extract objectPath from `file.url` during delete
uploadsUrlMarker: '/uploads/',
// Optional (default: false): save files as <uuidv4>_<hash>.ext for unique, non-guessable filenames
renameToUuid: false,
// Optional: private folder (access via Admin JWT, user JWT with matching documentId, or HMAC-signed URL)
privateEnable: false,
privateFolder: 'private',
privateTTL: 60,
privateSecret: '',
privateUserDocumentIdField: 'id',
},
},
},
});To use path, pathDir, or private, you must call the upload service from your own API (e.g. custom routes/controllers) or extend the upload plugin controller. The default Strapi upload (Admin drag-and-drop or POST /api/upload) does not pass these options to the provider unless you extend it.
Custom route example: For a full example with controller, custom route, Swagger and permissions, see Custom route with path, pathDir and private.
If you want to expose your custom upload routes in the API documentation (Swagger/OpenAPI), you can use strapi-swagger-custom-paths. No dependency between packages β this provider works standalone; the Swagger package is an optional extra for developers who need documentation.
Install (optional):
# npm
npm install strapi-swagger-custom-paths
# yarn
yarn add strapi-swagger-custom-paths
# bun
bun add strapi-swagger-custom-paths1. Define Swagger in your custom route β use 01-custom.ts (or 01-custom.js) so the package can discover it. Add config.swagger to each route:
src/api/my-content-type/routes/01-custom.ts:
export default {
routes: [
{
method: 'POST',
path: '/my-content-types/upload',
handler: 'my-content-type.upload',
config: {
tags: ['My content type'],
swagger: {
tags: ['My content type'],
summary: 'Upload file',
description: 'Upload a file with path, pathDir (strapi-provider-upload-local-secure).',
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'File to upload',
},
// Add custom fields here (if necessary)
},
},
},
},
},
},
},
},
],
};2. Use getCustomSwaggerPaths() in your documentation plugin β the package scans all 01-custom.js/01-custom.ts files and merges their config.swagger into OpenAPI paths:
TypeScript (config/plugins.ts):
import { getCustomSwaggerPaths } from 'strapi-swagger-custom-paths';
export default () => ({
// ... your other plugins
documentation: {
enabled: true,
config: {
'x-strapi-config': { path: '/documentation' },
paths: getCustomSwaggerPaths(),
},
},
});JavaScript (config/plugins.js):
const { getCustomSwaggerPaths } = require('strapi-swagger-custom-paths');
module.exports = () => ({
documentation: {
enabled: true,
config: {
'x-strapi-config': { path: '/documentation' },
paths: getCustomSwaggerPaths(),
},
},
});The upload provider and the Swagger package work independently: you can use either one without the other.
All examples below use api::my-content-type.my-content-type. Replace with your own API. Ensure entry exists (create it first or get from request); use entry.documentId for refId.
TypeScript:
async upload(ctx) {
try {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('User not authenticated');
const { files } = ctx.request;
if (!files?.file) return ctx.badRequest('No file uploaded');
const file = Array.isArray(files.file) ? files.file[0] : files.file;
const entry = await strapi.documents('api::my-content-type.my-content-type').create({ data: {} });
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: 'custom-folder',
pathDir: 'custom-folder',
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Document file',
},
},
files: file,
});
return result;
} catch (error) {
return ctx.badRequest(error.message);
}
}JavaScript:
async upload(ctx) {
try {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('User not authenticated');
const { files } = ctx.request;
if (!files?.file) return ctx.badRequest('No file uploaded');
const file = Array.isArray(files.file) ? files.file[0] : files.file;
const entry = await strapi.documents('api::my-content-type.my-content-type').create({ data: {} });
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: 'custom-folder',
pathDir: 'custom-folder',
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Document file',
},
},
files: file,
});
return result;
} catch (error) {
return ctx.badRequest(error.message);
}
}Use path for Admin folders and pathDir for filesystem subdirectories. See path vs pathDir in Configuration Options for details.
TypeScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `user-${userId}`,
pathDir: `user-${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: `Document for user ${userId}`,
alternativeText: `Document for user ${userId}`,
},
},
files: file,
});
// Files will be saved in: uploads/user-123/[hash].ext
// URL will be: /uploads/user-123/[hash].extJavaScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `user-${userId}`,
pathDir: `user-${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: `Document for user ${userId}`,
alternativeText: `Document for user ${userId}`,
},
},
files: file,
});privateEnable: true)When privateEnable is true, you can upload files as private by setting data.private: true and data.pathDir to the user's document ID (e.g. user.id or your custom documentId field). The file is stored under /uploads/<privateFolder>/<documentId>/ and appears in the Admin Media Library with full access (view, move, delete). Via URL, access is allowed only if:
user.id) matches the path segment, or?token=...&expires=...) signed with privateSecret and not expired (within privateTTL seconds).Otherwise the server returns 403. The same logic applies everywhere (API and Admin): use Bearer or a signed URL. The provider patches the upload service so that find and findOne return signed URLs for private files; so in the Admin Library (and anywhere that uses those endpoints) the link you open or copy already has ?token=...&expires=... and works.
TypeScript:
const user = ctx.state.user;
if (!user) return ctx.unauthorized();
const documentId = String(user.id); // or user.documentId if privateUserDocumentIdField: 'documentId'
const result = await strapi.plugin('upload').service('upload').upload({
data: {
pathDir: documentId,
private: true,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Private file',
},
},
files: file,
});
// File saved in: uploads/private/<documentId>/[hash].ext
// URL: /uploads/private/<documentId>/[hash].ext
// Access: Admin JWT, user JWT (documentId match), or HMAC-signed URL.JavaScript:
const user = ctx.state.user;
if (!user) return ctx.unauthorized();
const documentId = String(user.id); // or user.documentId if privateUserDocumentIdField: 'documentId'
const result = await strapi.plugin('upload').service('upload').upload({
data: {
pathDir: documentId,
private: true,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Private file',
},
},
files: file,
});Note: The provider registers a middleware that intercepts GET /uploads/<privateFolder>/* and checks auth before serving. If private files are served without auth, ensure this middleware runs before Strapi's static file middleware (e.g. load the upload plugin early or adjust middleware order in your app).
About moving files in the Admin Media Library
Moving a file from one folder to another in the Admin (e.g. from "Documents" to "Private", or the other way around) does not change whether the file is public or private. That is decided when the file is uploaded: private files are stored in a special protected path on the server, and public files elsewhere. Moving between folders only changes how you organize them in the Media Libraryβit does not move the file on the server or update its access rules.
In short: private files stay private even if you move them to another folder, and public files stay public even if you move them. To change access, you would need to re-upload the file with the desired settings.
Strapi supports uploading multiple files in one call. In that case, files is an array and fileInfo should be an array with the same order:
TypeScript:
await strapi.plugin('upload').service('upload').upload({
data: {
path: 'Documents',
pathDir: 'documents',
fileInfo: [
{ name: 'a.png', caption: 'A' },
{ name: 'b.png', caption: 'B' },
],
},
files: [fileA, fileB],
});JavaScript:
await strapi.plugin('upload').service('upload').upload({
data: {
path: 'Documents',
pathDir: 'documents',
fileInfo: [
{ name: 'a.png', caption: 'A' },
{ name: 'b.png', caption: 'B' },
],
},
files: [fileA, fileB],
});upload.replace)When replacing an existing upload (keeping the same DB entry), Strapi calls upload.replace(id, { data, file }).
This provider patches replace too (when present) so path / pathDir behave the same:
TypeScript:
await strapi.plugin('upload').service('upload').replace(fileId, {
data: {
path: 'Documents',
pathDir: 'documents/2026',
fileInfo: { name: 'new-name.png' },
},
file: newFile,
});JavaScript:
await strapi.plugin('upload').service('upload').replace(fileId, {
data: {
path: 'Documents',
pathDir: 'documents/2026',
fileInfo: { name: 'new-name.png' },
},
file: newFile,
});TypeScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `contracts/${userId}`,
pathDir: `contracts/${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Contract document',
alternativeText: 'Contract document',
},
},
files: file,
});
// Files will be saved in: uploads/contracts/123/[hash].extJavaScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `contracts/${userId}`,
pathDir: `contracts/${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Contract document',
alternativeText: 'Contract document',
},
},
files: file,
});uploads/
βββ user-123/
β βββ abc123def456.jpg
β βββ ghi789jkl012.png
βββ user-456/
β βββ mno345pqr678.pdf
βββ user-789/
βββ stu901vwx234.docxuploads/
βββ contracts/
β βββ user-123/
β β βββ contract-001.pdf
β βββ user-456/
β βββ contract-002.pdf
βββ invoices/
β βββ invoice-001.pdf
β βββ invoice-002.pdf
βββ profiles/
βββ user-123-avatar.jpgpath (Admin) vs pathDir (filesystem)This provider supports two optional fields in upload().data:
path: creates/targets a Media Library folder (Admin UI) by automatically setting fileInfo.folder (it creates the folder hierarchy if needed).pathDir: controls the physical directory under public/uploads (NFD normalized + sanitized) and therefore affects the final file.url.Compatibility: If you do not send pathDir, the provider uses path as filesystem directory by default (can be changed with usePathAsPathDir).
See Basic Usage or Dynamic Path Organization for code examples.
providerOptions)All options are optional.
debug: boolean (default false)
Enable provider debug logs. Also supports env STRAPI_PROVIDER_UPLOAD_LOCAL_PATH_DEBUG=1.
strictPathDir: boolean (default false)
If pathDir sanitizes to an empty string, throw an error instead of falling back.
cleanupEmptyDirs: boolean (default false)
After deleting a file, try to remove empty parent directories under public/uploads.
cleanupEmptyAdminFolders: boolean (default false)
After deleting a file, try to delete empty Media Library folders (Admin) too.
It only deletes a folder when it has no child folders and no other files (excluding the file being deleted),
then repeats the same check for the parent folder (recursive upwards).
usePathAsPathDir: boolean (default true)
If pathDir is missing, use path as the filesystem directory (compatibility mode).
Set to false if you want path to be Admin-only and never affect filesystem paths.
uploadsUrlMarker: string (default "/uploads/")
Marker used to extract the objectPath from file.url/previewUrl during delete.
renameToUuid: boolean (default false)
If true, saved file name becomes <uuidv4>_<hash><ext> (e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890_abc123hash.ext).
Strapi's hash is still present; the UUID makes the filename unique and non-guessable.
privateEnable: boolean (default false)
Enables the private folder. Files under /uploads/<privateFolder>/<documentId>/ are only accessible when authenticated: Admin has full access; API requires JWT user's documentId to match the path, or a valid HMAC-signed URL.
privateFolder: string (default "private")
Name of the private folder in filesystem and Admin Media Library (no leading/trailing slashes). URL pattern: /uploads/<privateFolder>/<documentId>/file.ext.
privateTTL: number (default 60)
TTL in seconds for HMAC-signed URLs (?token=...&expires=...).
privateSecret: string (optional)
Secret for signing HMAC URLs. Required if you want shareable time-limited links. Use a long random value (e.g. 32 bytes). See Generating secrets below.
privateUserDocumentIdField: string (default "id")
Field on the users-permissions user entity used as documentId for path matching. The path must be /uploads/private/<documentId>/... and the authenticated user's value for this field must equal <documentId>, or 403 is returned.
For Admin and API to open/copy working links to private files, set privateSecret. The provider then patches the upload service so that find and findOne return signed URLs (?token=...&expires=...) for private filesβsame logic everywhere.
When you need a secure random value for privateSecret (or any other secret), generate one with Node.js:
Hex (64 characters):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Base64:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Use the output in your config (e.g. in providerOptions.privateSecret) or in an environment variable; do not commit secrets to version control.
Admin folder segments (from data.path) are sanitized as follows:
a-zA-Z0-9, space, _, -This avoids invalid folder names in the Media Library DB.
This is a drop-in replacement for @strapi/provider-upload-local. To migrate:
Install the new package:
# npm
npm install strapi-provider-upload-local-secure
# yarn
yarn add strapi-provider-upload-local-secure
# bun
bun add strapi-provider-upload-local-secureUpdate plugins configuration (config/plugins.ts or config/plugins.js): change provider: 'local' to provider: 'strapi-provider-upload-local-secure'.
Add dynamic paths (optional): call the upload service from your controller or custom route and pass path / pathDir in data:
TypeScript:
const file = Array.isArray(files.file) ? files.file[0] : files.file;
await strapi.plugin('upload').service('upload').upload({
data: {
path: `user-${userId}`,
pathDir: `user-${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: { name: file.originalFilename, caption: 'Document file' },
},
files: file,
});JavaScript:
const file = Array.isArray(files.file) ? files.file[0] : files.file;
await strapi.plugin('upload').service('upload').upload({
data: {
path: `user-${userId}`,
pathDir: `user-${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: { name: file.originalFilename, caption: 'Document file' },
},
files: file,
});For local development, testing, and publishing, see Development guide.
The provider has been tested with:
To run tests locally, see Development guide.
'strapi-provider-upload-local-secure' in your configurationdata.path in the upload callAdd console logs to see what's happening:
// Add temporary logs to your upload controller
console.log('Upload data:', {
path: data.path,
fileInfo: data.fileInfo
});git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)This provider was inspired by:
Strapi upload-local β Official local upload provider; base structure and upload/delete flow.
strapi-provider-upload-aws-s3-advanced (zoomoid) β Patterns for normalizePrefix, join, and provider layout (see source).
Strapi Team β For the original upload provider implementation.
Community β For feedback and suggestions.
β οΈ Important: Modified version of the official @strapi/provider-upload-local. Backward compatible; adds path organization, private folder with secure URLs, and other features not in the original.
Share your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.
Submit
