This article assumes you’re using django-tenants for schema-based multitenancy (separate PostgreSQL schema per tenant). The authorization pattern described here works specifically with django-tenants’ schema_context() approach. If you’re curious, read the AWS cognito part as well.
The Problem
When building a multitenant application with django-tenants, we needed to control which PostgreSQL schema each user could access. Each tenant has its own isolated schema, but we needed a lightweight authorization mechanism to ensure users could only access their assigned tenant’s data.
This may not be the best approach but fine enough for a simple schema authorization.
We wanted to avoid:
- Heavy authorization libraries like django-tenant-users (too complex for our use case)
- Complex RBAC systems (we only needed tenant-level isolation)
A simple Solution
The thing is a simple user attribute-based authorization pattern: store the tenant schema assignment directly on the user model and validate every request against that database value.
In my previous article about AWS Cognito, I had explained about custom attribute-based multitenancy in AWS Cognito, where users have tenant identifiers as custom attributes.
I mean, this standard is not something newer, user attribute based authorization has been always there. Above article by AWS Cognito is a one of such. While, I recommend not to use Cognito for SSO with Microsoft (explained in previous articles on this)
How this approach works
-
User authenticates - Client provides credentials (token or session cookie), Django authenticates the user
-
Load schema from database - Django loads the authenticated user object from the database, which includes the
schemaattribute (e.g.,user.schema = 'tenant_a') -
Client declares intent - Client sends a request with the
customerquery parameter indicating which tenant they want to access (e.g.,?customer=tenant_a) -
Validation happens - The
SchemaAccessPermissionclass compares the requested customer parameter against the user’s database schema:- If
request.GET.get("customer") == request.user.schema→ Proceed - If
request.user.schema == "ALL"→ Super admin, proceed - Otherwise → Return 403 Forbidden
- If
-
Schema context switch - If validation passes, django-tenants switches to the requested PostgreSQL schema using
schema_context(), ensuring all queries are isolated to that tenant’s data
sequenceDiagram
participant Client
participant Django Auth
participant Database
participant Permission Check
participant Tenant Schema
Client->>Django Auth: GET /api/equipment/?customer=tenant_a
Django Auth->>Database: Validate credentials
Database-->>Django Auth: User object (schema='tenant_a')
Django Auth->>Permission Check: Check request.user.schema vs query param
alt Query param matches DB schema
Permission Check->>Tenant Schema: schema_context(tenant_a)
Tenant Schema-->>Client: Access granted
else Query param doesn't match
Permission Check-->>Client: 403 Forbidden
end
Note over Permission Check: Source of truth: Database<br/>NOT the request parameter
Implementation
These are not the full code, but more than enough to understand the basic idea..
User Model
Extend Django’s AbstractUser to add the schema attribute. I assume this is something you’ve already done.
from django.contrib.auth.models import AbstractUser
from django.db import models
class LocalUserAccounts(AbstractUser):
"""Custom user model inheriting from AbstractUser"""
schema = models.CharField(max_length=100, null=True, blank=True) #Our main field of focus
email = models.EmailField(max_length=254, unique=True)
show_intro = models.BooleanField(null=True, blank=True, default=True)
# schema stores: 'tenant_a', 'tenant_b', or 'ALL' for super admins <-- as per our logic moving forward
class Meta:
verbose_name_plural = "LocalUserAccounts"
verbose_name = "LocalUserAccounts"
Register the custom user model in settings: If inherited, It should be registed in settings.
# settings.py
AUTH_USER_MODEL = 'tenant.LocalUserAccounts'
By inheriting from AbstractUser, you get all standard Django user fields (username, password, email, etc.) plus the custom schema field.
Authentication Setup
This pattern works with both Token Authentication and Session Authentication authentication. Therefore go-ahead and lookup the DRF Token based auth section. I’m not walking you through the code setup much, the idea is to deliver the key idea of user based attributes.
Configure DRF token and session authentication. See DRF Authentication documentation for more details. Ensure Token and Session are added in REST_FRAMEWORK and INSTALLED_APPS
A user may or may not have a token. Tokens are generated for users who need API access, while browser users rely on Django’s session cookies after login.
2. Middleware (Optional - but i find it handy)
Add middleware to extract the customer parameter for convenience. This can be then read across all views.
# eboost/client/middleware.py
def schema_middleware(get_response):
"""Middleware to add requested_schema to request"""
def middleware(request):
request.requested_schema = request.GET.get("customer", "").lower() or None
response = get_response(request)
return response
return middleware
3. Permission Class
from rest_framework import permissions
class SchemaAccessPermission(permissions.BasePermission):
"""Validates user's schema attribute against requested tenant"""
def has_permission(self, request, view):
if request.user.is_anonymous:
return False
# Get tenant from query parameter (recommended) or header
requested_schema = request.GET.get("customer") or request.headers.get("customer")
if requested_schema:
requested_schema = requested_schema.lower()
# Check: DB value vs. client request
if requested_schema == request.user.schema:
return True
elif request.user.schema == "ALL": # Super admin
return True
return False
Understanding the permission class:
-
What it is - A custom Django REST Framework permission class that inherits from
BasePermission. It implements thehas_permission()method which DRF calls automatically before executing any view decorated with this permission. -
How it validates - Compares the client’s requested tenant (from
?customer=tenant_a) against the user’s database schema (request.user.schema). If they match or if user hasschema='ALL'(super admin), it returnsTrue(allow). Otherwise returnsFalse(triggers 403 Forbidden). -
Where it’s used - Applied as a decorator on API views using
@permission_classes([SchemaAccessPermission]). DRF executes this check before your view code runs, ensuring unauthorized requests never reach your business logic or database queries.
flowchart TD
A[Authenticated User] --> B{Load user.schema from DB}
B --> C[user.schema = 'tenant_a']
C --> D[Client sends request]
D --> E[Scenario 1: customer=tenant_a]
D --> F[Scenario 2: customer=tenant_b]
D --> G[Scenario 3: user.schema=ALL]
E --> H{Compare: tenant_a == request.user.schema?}
H -->|Yes| I[Allowed]
F --> J{Compare: tenant_b == request.user.schema?}
J -->|No| K[403 Forbidden]
G --> L{request.user.schema == ALL?}
L -->|Yes| M[Allowed to Any Tenant]
Whether ot not to use query param header is something you can decide. I use header and I had to allow those headers explicitly.
4. Usage in Views
This is one example view where I use this decorator
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_tenants.utils import schema_context
@api_view(["GET"])
@permission_classes([IsAuthenticated, SchemaAccessPermission])
def get_equipment(request):
# Permission passed - safe to use the customer parameter
customer = request.GET.get("customer")
with schema_context(customer): # django-tenants context switch
equipment = Equipment.objects.all()
return Response(EquipmentSerializer(equipment, many=True).data)
request.user.schema← (from database)request.GET.get("customer")← (from client/request)- Compare them before granting access
When to Use This Pattern
- One user belongs to exactly one tenant
- Schema-per-tenant isolation (using django-tenants)
- Simple admin/regular user distinction
And, not suitable for multiple schema permission or object based permission. Of course, you can extend the logic to build a hierarchical authorization system
When you outgrow this pattern:
- Use django-tenant-users for M2M user-tenant relationships
For granular permissions:
- Add django-guardian for object-level permissions. But I find Guardian not easy to manage when it comes to Multi-tenant apps like Django tenants, since we need to manage permissions from Admin panel, I had to login to all of them separately and assign permission. Or you may also write a wrapper around them to fix it, but I find it handy in a flat database than a schema based one If you’re going to manage it from Django Admin.
Conclusion
User attribute-based authorization provides a lightweight, secure pattern for tenant isolation when each user belongs to exactly one tenant. I think this approach is very minimal and extensible. I got inspired from the AWS Cognito custom user attributes. Before that, I did not think of attaching something to user attribute for auth checks.
Here are some useful refs:
- AWS Cognito: Custom-attribute multi-tenancy
- django-tenants Documentation
- django-tenant-users Documentation
- Building Multi-Tenant Apps with Django