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

  1. User authenticates - Client provides credentials (token or session cookie), Django authenticates the user

  2. Load schema from database - Django loads the authenticated user object from the database, which includes the schema attribute (e.g., user.schema = 'tenant_a')

  3. Client declares intent - Client sends a request with the customer query parameter indicating which tenant they want to access (e.g., ?customer=tenant_a)

  4. Validation happens - The SchemaAccessPermission class 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
  5. 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:

  1. What it is - A custom Django REST Framework permission class that inherits from BasePermission. It implements the has_permission() method which DRF calls automatically before executing any view decorated with this permission.

  2. 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 has schema='ALL' (super admin), it returns True (allow). Otherwise returns False (triggers 403 Forbidden).

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

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: