Skip to content

Strapi File Security — Signed URLs, Access Control, and Private Files

DodaTech Updated 2026-06-28 9 min read

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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

Can I set per-file access permissions in Strapi?

Strapi does not have built-in per-file permissions. You must implement custom logic using the related content entries or a custom file access service that checks user roles, ownership, or subscription status.

How do I make some files public and others private?

Store public and private files in separate folders or use a custom field on the file to indicate visibility. Create a serving endpoint that checks the visibility flag and the user's authentication status.

Does S3 signed URL work with CloudFront?

Yes. You can generate CloudFront signed URLs or signed cookies for content served through CloudFront. CloudFront signed URLs support additional controls like IP restrictions and referer restrictions.

What happens to existing file URLs when I implement file security?

Existing file URLs will break if you move files or add authentication. Plan the migration carefully: (1) Copy files to private storage, (2) Deploy the secure serving endpoint, (3) Update all content references to use the new endpoint, (4) Remove public access to old URLs.

Can I integrate file security with user subscriptions?

Yes. In your file-serving endpoint, check the user's subscription status before serving the file. Premium content is served only to users with active subscriptions. Free users receive a 403 or a preview version.

Mini Project

Your task: Build a private file delivery system.

  1. Configure a custom middleware that intercepts file requests from /uploads/ and checks for a valid API token.
  2. Create a custom endpoint /api/files/:id/serve that:
    • 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
  3. For private files, generate temporary access URLs with 5-minute expiration.
  4. Implement hotlink protection that blocks non-browser User-Agents and unauthorized referrers.
  5. 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:

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro