Strapi File Security — Signed URLs, Access Control, and Private Files
In this tutorial, you will learn how to secure files in Strapi — implementing signed URLs for time-limited access, configuring access control for private files, and protecting uploaded media from unauthorized downloads and hotlinking.
What You'll Learn
- Why default file access is insecure and how to mitigate it
- How to implement signed URLs for temporary file access
- How to configure private file storage with access control
- How to prevent hotlinking from other websites
- How to integrate file security with S3 and Cloudinary
- How to implement user-specific file access policies
Why It Matters
By default, uploaded files in Strapi are publicly accessible. Anyone with the file URL can download any uploaded file. For public content like article images, this is fine. But for private documents, user profile photos, or paid content, you need to restrict access. Without file security, private documents are exposed to anyone who guesses or finds the URL.
Real-World Use
An online course platform hosts video lessons and PDF workbooks. Only paid subscribers should access these files. With signed URLs, the platform generates temporary access links that expire after 30 minutes. A subscriber watching a lesson gets a valid signed URL. An unauthenticated user who copies the link finds it expired. The platform also prevents hotlinking so students cannot share lesson URLs on social media.
Learning Path
flowchart LR A["File Management"] --> B["File Security
-- You are here"]:::current B --> C["Plugin Ecosystem"] C --> D["Custom Plugins"] classDef current fill:#4945ff,color:#fff,stroke-width:2px
Understanding the Problem
The default local upload provider stores files in /public/uploads/. These are served directly by Strapi's static file middleware, which does not check authentication.
// Default behavior — no access control:
// GET /uploads/private-document.pdf
// Returns the file regardless of who requests it
// There is no authentication check for static files.
// Anyone who knows the URL can download any file.
This is fine for public assets but dangerous for private content. Solutions include moving files to a private directory with a proxy endpoint, using cloud providers with signed URLs, or implementing custom middleware.
Private File Storage with Custom Middleware
The most flexible approach: store files outside the public directory and serve them through a custom endpoint that checks authentication.
// Step 1: Move files to private storage
// config/plugins.js
module.exports = {
upload: {
config: {
provider: "local",
providerOptions: {
// Store files in a private directory
sizeLimit: 10 * 1024 * 1024,
},
// Override the local provider to use private path
},
},
};
// Step 2: Create a custom file-serving endpoint
// src/api/file/controllers/file.js
module.exports = {
async serve(ctx) {
const fileId = ctx.params.id;
const user = ctx.state.user;
// Check authentication
if (!user) {
return ctx.unauthorized("Authentication required");
}
// Check permissions (example: only if user owns the file)
const file = await strapi.db.query("plugin::upload.file").findOne({
where: { id: fileId },
});
if (!file) {
return ctx.notFound("File not found");
}
// Add custom access logic here
// Example: check if user has access to this file's parent content
// Read and serve the file
const fs = require("fs");
const path = require("path");
const filePath = path.join(strapi.dirs.static.public, file.url);
if (!fs.existsSync(filePath)) {
return ctx.notFound("File not found on disk");
}
ctx.set("Content-Type", file.mime);
ctx.set("Content-Disposition", `inline; filename="${file.name}"`);
ctx.body = fs.createReadStream(filePath);
},
};
Signed URLs with S3
Amazon S3 supports presigned URLs that grant temporary access to private objects.
// Generate a presigned S3 URL
const AWS = require("aws-sdk");
const s3 = new AWS.S3({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
});
async function getSignedFileUrl(fileKey, expiresInSeconds = 300) {
const params = {
Bucket: process.env.AWS_S3_BUCKET,
Key: fileKey, // e.g., "uploads/pizza_abc123.jpg"
Expires: expiresInSeconds, // URL expires in 5 minutes
};
return s3.getSignedUrlPromise("getObject", params);
}
// In a Strapi controller:
async getFile(ctx) {
const fileId = ctx.params.id;
const file = await strapi.db.query("plugin::upload.file").findOne({
where: { id: fileId },
});
if (!file) return ctx.notFound();
// Check permissions here
// The file.hash + file.ext gives the S3 key
const key = `${file.hash}${file.ext}`;
const signedUrl = await getSignedFileUrl(key, 300);
// Redirect to the signed URL or return it
return { url: signedUrl, expiresIn: 300 };
}
The presigned URL includes authentication parameters that expire, so even if the URL is shared, it only works for a limited time.
Signed URLs with Cloudinary
Cloudinary provides signed delivery through private CDN or delivery type restrictions.
// Cloudinary configuration for private files
const cloudinary = require("cloudinary").v2;
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
// Generate a signed Cloudinary URL
function getSignedCloudinaryUrl(publicId, options = {}) {
return cloudinary.url(publicId, {
...options,
sign_url: true, // Sign the URL
type: "authenticated", // Requires signed delivery
resource_type: "image",
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour
});
}
// Usage:
const signedUrl = getSignedCloudinaryUrl("pizza_abc123", {
width: 800,
height: 600,
crop: "fill",
});
Cloudinary's authenticated delivery ensures that only users with a signed URL can access the file.
Preventing Hotlinking
Hotlinking is when other websites embed your images directly, using your bandwidth.
// Strapi middleware to prevent hotlinking
// src/middlewares/anti-hotlink.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
if (ctx.url.startsWith("/uploads/")) {
const referer = ctx.request.headers.referer || "";
const allowedDomains = config.allowedDomains || [
"yourdomain.com",
"www.yourdomain.com",
"localhost:3000",
];
// Check if the referer is empty (direct access) or allowed
const isAllowed = referer === "" ||
allowedDomains.some((domain) => referer.includes(domain));
if (!isAllowed) {
// Return a transparent 1x1 pixel instead of the real image
ctx.status = 200;
ctx.set("Content-Type", "image/gif");
ctx.body = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
return;
}
}
await next();
};
};
Register the middleware in config/middlewares.js:
module.exports = [
// ... other middlewares
{
name: "global::anti-hotlink",
config: {
allowedDomains: ["myrecipes.com", "www.myrecipes.com"],
},
},
];
User-Specific File Access
Implement access control where files are tied to specific users:
// src/api/file/services/file.js
module.exports = {
async getAccessibleFiles(userId) {
// Get files that the user has access to
// This might be files they uploaded, files in their subscription tier, etc.
const uploadFiles = await strapi.db.query("plugin::upload.file").findMany({
where: {
// Custom logic based on your access model
related: {
article: {
author: userId, // Files from articles the user authored
},
},
},
});
return uploadFiles;
},
async canAccessFile(userId, fileId) {
const file = await strapi.db.query("plugin::upload.file").findOne({
where: { id: fileId },
populate: ["related"],
});
if (!file) return false;
// Check various access rules
const hasDirectAccess = file.createdBy === userId;
const hasRelatedAccess = file.related?.some((rel) => {
return rel.author === userId || rel.visibility === "public";
});
return hasDirectAccess || hasRelatedAccess;
},
};
File Security Checklist
- Files stored in private directory (not
/public/uploads/) - File-serving endpoint checks authentication
- Signed URLs for time-limited access
- Hotlink protection enabled
- File type validation on upload
- File size limits enforced
- Malware scanning on upload (for user-generated content)
- File URL not exposing server paths or structure
- Regular cleanup of orphaned files
Common Mistakes
Assuming uploaded files are private by default. They are not. Any file in
/public/uploads/is accessible to anyone. You must actively implement file security.Storing sensitive files alongside public assets. Keep private files in a separate storage location or directory that is not served by the static file middleware.
Not expiring signed URLs. Signed URLs that never expire defeat their purpose. Set short expiration times (5-30 minutes) and generate fresh URLs when needed.
Exposing file paths in error messages. When a file access fails, do not reveal the full file path in error responses. Attackers can use this information to map your storage structure.
Ignoring direct file access. Users can still access files directly if they know the URL. Signed URLs and authentication middleware prevent this, but only if implemented correctly.
Practice Questions
Why are uploaded files in Strapi accessible to anyone by default? Answer: The default local provider stores files in
/public/uploads/, which is served by Strapi's static file middleware without authentication checks. There is no built-in access control for static files.How does a signed URL protect file access? Answer: A signed URL includes authentication parameters that expire after a set time. Even if someone obtains the URL, they can only access the file until the expiration time.
What is hotlinking and how do you prevent it? Answer: Hotlinking is when other websites embed your images directly, using your bandwidth. Prevent it by checking the HTTP Referer header and blocking requests from unauthorized domains.
Challenge: Implement a complete file security system: (1) Configure Strapi to store files in a private directory, (2) Create a custom endpoint that serves files only to authenticated users, (3) Implement role-based file access (public users see public files, premium users see premium files), (4) Add hotlink protection that blocks requests from unknown domains, (5) For S3 users: implement presigned URLs with 10-minute expiration, (6) Test each security measure by attempting unauthorized access.
FAQ
Mini Project
Your task: Build a private file delivery system.
- Configure a custom middleware that intercepts file requests from
/uploads/and checks for a valid API token. - Create a custom endpoint
/api/files/:id/servethat:- Authenticates the user
- Checks if the user has access to the file's parent content
- Returns the file with proper headers for inline display
- Logs each file access
- For private files, generate temporary access URLs with 5-minute expiration.
- Implement hotlink protection that blocks non-browser User-Agents and unauthorized referrers.
- Test: unauthenticated requests should get 401, unauthorized users should get 403, authorized users should get the file, and expired URLs should return 410 Gone.
What's Next
Now that you understand file security, proceed to Plugin Ecosystem to explore the Strapi marketplace, understand plugin types, and learn how to install and manage plugins. After that, build your own Custom Plugins.
Related lessons:
- REST API Security — Protecting API endpoints
- Node.js Security — General security best practices
- WordPress File Security — Compare file security approaches
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro