ScreenshotNeo

BlogEngineering

How to Isolate Templates and Assets Per User

A practical guide to tenant-safe templates, database queries, static files, and private uploads—with Django patterns, storage policies, tests, and troubleshooting.

By the ScreenshotNeo team1 October 202610 min read

Direct answer: resolve a trusted tenant and user context immediately after authentication, then carry that context through every database query, template lookup, storage-key operation, cache key, background job, and authorization check. A tenant directory or object prefix helps organize data, but it is not an authorization boundary by itself. The server must independently reject cross-tenant reads and writes.

This guide covers the isolation choices, a Django implementation pattern, object-storage policy design, public static files versus private media, testing, operations, and common failure modes.

1. Define the isolation boundary first

Decide what must be isolated before choosing a database or bucket layout. In most applications, the boundary is the tenant (organization, workspace, or account), with users inside that tenant. Some data is tenant-owned, some is user-owned, and some is shared application data.

Data Typical owner Required check
Branding, pages, templates Tenant Resolved tenant ID
Uploaded avatars or documents User or tenant Tenant ID plus user/object ownership
Static build files Application release Release/version policy
Sessions and permissions User and tenant membership Authenticated identity and role
Cache entries Tenant, user, or global Matching scope in every key

Never accept a tenant ID, user ID, filename, or storage prefix from the browser as proof of ownership. Resolve identity from a server-controlled session or verified token claims, or from a trusted host-to-tenant mapping. Then construct the expected scope on the server.

2. Choose a multitenancy model

There are three common database arrangements, as documented by the django-tenants maintainers:

Model Isolation Operational profile Main risk
Database per tenant Strongest database boundary Tenant backup and restore are straightforward; provisioning, migrations, and connection management are heavier Many databases increase operational overhead
Schema per tenant Namespace separation in one database A compromise between isolation, simplicity, and performance Schema lifecycle and migrations require discipline
Shared schema with tenant key Application and query-policy boundary Efficient to operate at scale One missed tenant filter can disclose another tenant’s data

Choose based on regulatory and contractual requirements, backup scope, migration complexity, connection limits, noisy-neighbor behavior, and failure impact. Shared-schema designs need tenant-aware uniqueness rules, queues, caches, exports, reporting, and every database query.

3. Resolve tenant context before doing any work

A request should have one trusted context object. Resolve it before loading a template, querying a model, generating a storage key, or creating a signed download URL.

from dataclasses import dataclass

@dataclass(frozen=True)
class TenantContext:
    tenant_id: str
    user_id: str
    role: str


def resolve_context(request) -> TenantContext:
    # Example only: request.user and membership lookup are server controlled.
    user = request.user
    if not user.is_authenticated:
        raise PermissionError("Authentication required")

    membership = (
        TenantMembership.objects
        .select_related("tenant")
        .get(user_id=user.id, tenant_id=request.session["active_tenant_id"])
    )
    return TenantContext(
        tenant_id=str(membership.tenant_id),
        user_id=str(user.id),
        role=membership.role,
    )

Do not derive the active tenant from an unverified query parameter. If a user belongs to several tenants, validate the selected tenant against the membership table on every request or issue a server-signed context.

4. Enforce tenant scope in database access

For a shared schema, every tenant-owned row should contain a non-null tenant key. Put the filter close to the data-access layer so callers cannot accidentally omit it.

class Project(models.Model):
    tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
    name = models.CharField(max_length=200)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["tenant", "name"],
                name="unique_project_name_per_tenant",
            )
        ]


def get_project(context, project_id):
    return Project.objects.get(
        id=project_id,
        tenant_id=context.tenant_id,
    )

Apply the same rule to updates and deletes. A check only on the initial list endpoint is insufficient:

Project.objects.filter(
    id=project_id,
    tenant_id=context.tenant_id,
).update(name=new_name)

Database row-level policies can provide defense in depth where your database supports them. They do not replace application authorization, because background workers, administrative tools, and storage code still need a correct context.

5. Load tenant-specific templates safely

A tenant-aware loader should search the tenant directory first and then fall back to shared templates. The django-tenants file-handling documentation describes this pattern: tenant-specific templates override the standard search path while shared templates remain available as a fallback.

Keep the tenant path derived from the resolved context, never from a template name supplied by the client. Also protect against path traversal by letting the template loader resolve names rather than concatenating arbitrary filesystem paths.

# Conceptual loader order
TEMPLATES = [
    "tenants/{tenant_id}/templates/",  # resolved by server-side context
    "templates/",                      # shared fallback
]

In Django, configure the tenant-aware loader or middleware provided by your multitenancy setup before the normal filesystem and app-directory loaders. A common design is:

  1. Resolve the tenant from the authenticated request.
  2. Store it in request-local context for the duration of the request.
  3. Make the tenant-aware loader search tenants/<tenant_id>/templates/.
  4. Fall back to shared templates only when no tenant override exists.
  5. Prevent templates from one tenant from being selected by a different context.

Template caching must include the tenant ID. A cache key such as template:dashboard is unsafe when the rendered result contains tenant branding.

6. Scope static files and user media separately

The django-tenants file-handling guide describes tenant-aware finders, storage handlers, loaders, and tenant-relative paths. It documents tenant-specific subdirectories for both static output and media output. A practical layout is:

static/
  shared/
  tenants/{tenant_id}/
media/
  tenants/{tenant_id}/users/{user_id}/assets/{asset_id}/

Use immutable object IDs in media keys. Do not use an original filename as the identity of an object, and do not allow a client to choose another tenant’s prefix.

def asset_key(context, asset_id, extension):
    safe_extension = extension.lower().lstrip(".")
    return (
        f"tenants/{context.tenant_id}/"
        f"users/{context.user_id}/assets/{asset_id}.{safe_extension}"
    )

The key is an identifier, not proof of authorization. Before every download, signed URL, copy, delete, or metadata request:

  1. Resolve the current tenant and user.
  2. Look up the asset record by immutable asset ID.
  3. Verify its tenant and ownership rules.
  4. Construct or retrieve the expected storage key.
  5. Only then perform the storage operation.

7. Keep public static assets away from private media

Public static files and user uploads have different delivery policies. Cookiecutter Django’s storage documentation describes a layout where static/ is publicly readable and media/ contains uploads. It warns that making an entire container public can expose both prefixes when they share one public policy.

Use one of these designs:

  • Separate public and private buckets or containers.
  • Separate prefixes with provider policies that explicitly restrict access.
  • A public static origin and a private media origin behind a CDN.
  • Private media with short-lived signed URLs.

For sensitive media, retain signed-query authentication or use a CDN that authenticates to a private origin. Do not put a long-lived object-storage credential in browser code.

8. Add storage policy controls

Object-storage providers can add policy-level isolation. AWS’s sample architecture describes tenant and user object tags together with an access point per tenant. Oracle’s security guidance describes policies that constrain both a bucket and an object-name pattern, with conditions for a specific user. These controls work best with application authorization and short-lived credentials.

Useful controls include:

  • Tenant and user tags on objects.
  • One access point or equivalent policy boundary per tenant.
  • Bucket and object-name conditions.
  • Short-lived credentials or signed URLs.
  • Immutable object IDs and audit records.

Record the tenant, user, object ID, action, and allow/deny decision in an audit trail. Audit data itself must be scoped and protected.

9. Protect background jobs, caches, and exports

Isolation failures often occur outside the request handler. Include tenant context in:

  • Queue payloads and scheduled jobs.
  • Cache keys and invalidation events.
  • Search indexes and analytics dimensions.
  • Webhook processing.
  • CSV, PDF, and backup exports.
  • Temporary directories and generated filenames.
# Safe queue payload shape
{
    "tenant_id": "tenant_123",
    "user_id": "user_456",
    "asset_id": "asset_789",
    "operation": "generate_preview"
}

The worker must re-check that the asset belongs to the supplied tenant before reading it. A tenant ID copied into a queue message is context to validate, not permission to trust blindly.

10. Test the negative cases

Isolation tests should attempt to cross every boundary independently. At minimum, test:

  • User A requesting User B’s asset ID.
  • A valid user with a different tenant host.
  • A valid object key under another tenant prefix.
  • A download token issued for another tenant.
  • A stale signed URL after ownership changes.
  • A background job with a mismatched tenant and asset.
  • A cache hit after switching the active tenant.
  • A tenant template name that matches a shared template.
  • Bulk list, search, export, and admin endpoints.

Each request should be denied or return a non-disclosing response. Also verify that logs, error pages, metrics, and object listings do not reveal another tenant’s names or keys.

11. Performance, reliability, and cost

Performance

  • Resolve tenant membership once per request and reuse the immutable context.
  • Index tenant keys together with frequently filtered columns such as object ID, slug, and creation time.
  • Use compound uniqueness constraints such as (tenant_id, name).
  • Keep cache keys tenant-scoped to prevent both leaks and incorrect cache reuse.
  • For schema-per-tenant designs, manage connection and migration overhead deliberately.

Reliability

  • Fail closed when tenant context is missing or membership cannot be verified.
  • Use immutable IDs so renames do not move or expose objects unexpectedly.
  • Make migrations and provisioning idempotent.
  • Test restore procedures at the same tenant boundary used for backups.
  • Keep authorization checks close to storage and database operations, not only in controllers.

Cost

Separate databases and schemas can increase provisioning, migration, connection, and backup-management costs. Shared schemas usually reduce infrastructure overhead but require more engineering in every query, policy, cache, and job. Private media delivery can add signed-URL, CDN, and storage-request costs; measure access patterns before selecting a layout.

12. Troubleshooting

Symptom Likely cause Fix
One tenant sees another tenant’s branding Template or rendered-page cache key lacks tenant ID Include tenant ID in the key and invalidate old entries
Users can guess another upload URL Storage URL is treated as authorization Authorize the asset record first and issue a short-lived signed URL
Private uploads are publicly readable Container-wide public policy covers the media prefix Separate the container or apply an explicit private policy
Tenant filter works on lists but not detail pages Detail query uses only a global object ID Filter detail, update, and delete queries by tenant ID too
Background previews use the wrong tenant Job payload omitted or trusted the tenant context Include tenant ID, then verify ownership in the worker
Tenant override is ignored Shared template loader runs before tenant loader Put the tenant-aware loader first, with shared fallback second
Cross-tenant data appears after switching workspaces Session, connection, or cache context was reused Clear or rebind request-local context and scope every cache key
Path traversal or unexpected files are loaded Client controls a filesystem path Accept a logical name, validate it, and resolve paths server-side

13. Or skip the browser setup

If your application needs screenshots of tenant-specific pages, you can run and secure a browser yourself, or use ScreenshotNeo. It provides one GET request for a PNG, JPEG, WebP, or PDF and supports custom headers, cookies, Authorization, user agents, timezone, geolocation, custom JavaScript and CSS, selector capture, waiting rules, blocking rules, device presets, retina scale, caching, signed links, asynchronous jobs, bulk capture, and an MCP server for AI agents.

Keep tenant authorization in your application: generate a URL or signed request only after checking the tenant and user. Then call the API.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

See the ScreenshotNeo API documentation for the full option set. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account to get 1,000 screenshots each month with no card.

14. FAQ

Is a tenant-specific folder enough?

No. Prefixes and directories organize data; authorization must still verify tenant and ownership before every read and write.

Should user uploads and static files share a bucket?

Only when provider policies can keep public static prefixes separate from private media. Separate containers are simpler when a container-wide public setting could expose uploads.

Which database model should a small product start with?

A shared schema can be efficient, provided every tenant-owned row, query, uniqueness rule, cache key, and job carries a validated tenant context. Choose a stronger boundary when requirements justify its operational cost.

Can a signed URL replace authorization?

No. Issue it only after checking the asset against the current tenant and user, and keep its lifetime short.

Where should tenant context live in a worker?

In an explicit job payload that the worker validates against the object or database record before doing any work.