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.
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:
- Resolve the tenant from the authenticated request.
- Store it in request-local context for the duration of the request.
- Make the tenant-aware loader search
tenants/<tenant_id>/templates/. - Fall back to shared templates only when no tenant override exists.
- 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:
- Resolve the current tenant and user.
- Look up the asset record by immutable asset ID.
- Verify its tenant and ownership rules.
- Construct or retrieve the expected storage key.
- 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.


