Multi tenant security handbook
Skill Crispa-ai/claude-code-config/skills/multi-tenant-security-handbook
Guide to tenant isolation, authentication, OAuth flows, and data protection in Crispa's multi-tenant Django app. Use when adding integrations or reviewing tenant-aware security.From its SKILL.md
npx -y skills add Crispa-ai/claude-code-config --skill multi-tenant-security-handbookAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
19.5 KB, as published. Nobody here has run it
Multi-Tenant Security Handbook
Complete guide to tenant isolation, authentication, security, and data protection in multi-tenant Django applications.
When to Use
- When implementing new tenant-aware features
- When debugging tenant isolation issues
- When reviewing authentication code
- When implementing new API endpoints
- Before deploying tenant-related changes
Tenant Isolation Principles
Critical Rules
- EVERY query MUST be tenant-aware - No exceptions
- NEVER trust client-provided tenant IDs - Always use authenticated tenant
- Test cross-tenant access - Verify users can't access other tenants' data
- Authentication REQUIRED - All tenant pages must be protected
Django Tenant Isolation Patterns
Pattern 1: Tenant Middleware
# middleware/tenant_middleware.py
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Extract tenant from subdomain or header
tenant_slug = self.get_tenant_slug(request)
# Load tenant
try:
request.tenant = Tenant.objects.get(slug=tenant_slug)
except Tenant.DoesNotExist:
return HttpResponseNotFound('Tenant not found')
response = self.get_response(request)
return response
def get_tenant_slug(self, request):
# From subdomain: acme.crispa.ai → acme
host = request.get_host()
slug = host.split('.')[0]
return slug
Pattern 2: Tenant-Aware Managers
# models/base.py
class TenantAwareManager(models.Manager):
def get_queryset(self):
# Automatically filter by current tenant
queryset = super().get_queryset()
if hasattr(self, 'tenant'):
queryset = queryset.filter(tenant=self.tenant)
return queryset
class TenantModel(models.Model):
tenant = models.ForeignKey('tenants.Tenant', on_delete=models.CASCADE)
objects = TenantAwareManager()
class Meta:
abstract = True
Pattern 3: Tenant Context Manager
# utils/tenant_context.py
from contextvars import ContextVar
_tenant_context = ContextVar('tenant', default=None)
def get_current_tenant():
return _tenant_context.get()
def set_current_tenant(tenant):
_tenant_context.set(tenant)
@contextmanager
def tenant_context(tenant):
"""Use in tests or Celery tasks"""
token = _tenant_context.set(tenant)
try:
yield
finally:
_tenant_context.reset(token)
# Usage in Celery task:
@shared_task
def process_invoices(tenant_id):
tenant = Tenant.objects.get(id=tenant_id)
with tenant_context(tenant):
# All queries use this tenant
invoices = Invoice.objects.all()
Pattern 4: Viewset Tenant Filtering
# api/views/invoice_viewset.py
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
def get_queryset(self):
# ✅ REQUIRED: Filter by request tenant
return super().get_queryset().filter(tenant=self.request.tenant)
def perform_create(self, serializer):
# ✅ REQUIRED: Set tenant on creation
serializer.save(tenant=self.request.tenant)
Authentication Patterns
Frontend Authentication (Next.js + Auth0)
Pattern 1: withAuthenticationRequired (Recommended)
// pages/[tenant]/invoices/index.tsx
import { withAuthenticationRequired } from "@auth0/auth0-react";
function InvoicesPage() {
// Page content
}
// CRITICAL: Both are required
export default withAuthenticationRequired(InvoicesPage);
export const getServerSideProps = async () => {
return { props: {} }; // Forces SSR
};
Why both are required:
withAuthenticationRequired: Auth0 client-side redirect to logingetServerSideProps: Forces SSR so Next.js router is available
Pattern 2: getServerSideProps with Session Check
// pages/[tenant]/admin/settings.tsx
import { getSession } from "@auth0/nextjs-auth0";
export const getServerSideProps = async (context) => {
const session = await getSession(context.req, context.res);
if (!session) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
// Optional: Check permissions
if (!session.user.isAdmin) {
return {
redirect: {
destination: `/${context.params.tenant}/unauthorized`,
permanent: false,
},
};
}
return {
props: {
user: session.user,
},
};
};
function SettingsPage({ user }) {
return <div>Settings for {user.email}</div>;
}
export default SettingsPage;
Backend Authentication (Django + JWT)
Pattern 1: Authentication Classes
# api/views/base.py
from rest_framework.permissions import IsAuthenticated
from rest_framework_simplejwt.authentication import JWTAuthentication
class BaseViewSet(viewsets.ModelViewSet):
authentication_classes = [JWTAuthentication]
permission_classes = [IsAuthenticated]
def get_queryset(self):
# Tenant from authenticated user
return super().get_queryset().filter(
tenant=self.request.user.tenant
)
Pattern 2: Custom Permissions
# api/permissions.py
from rest_framework import permissions
class IsTenantMember(permissions.BasePermission):
"""User must belong to the tenant"""
def has_permission(self, request, view):
return (
request.user.is_authenticated and
request.tenant == request.user.tenant
)
class IsTenantOwner(permissions.BasePermission):
"""User must be tenant owner"""
def has_object_permission(self, request, view, obj):
return (
request.user.is_authenticated and
obj.tenant == request.user.tenant and
request.user.role == 'owner'
)
Security Vulnerabilities to Prevent
Vulnerability 1: IDOR (Insecure Direct Object Reference)
# ❌ VULNERABLE: Client controls which invoice to access
def get_invoice(request, invoice_id):
invoice = Invoice.objects.get(id=invoice_id) # No tenant check!
return JsonResponse(invoice.to_dict())
# Attacker can access any invoice by guessing IDs
# ✅ SECURE: Filter by tenant
def get_invoice(request, invoice_id):
invoice = Invoice.objects.get(
id=invoice_id,
tenant=request.tenant # REQUIRED
)
return JsonResponse(invoice.to_dict())
# Or use get_object_or_404 with tenant filter
invoice = get_object_or_404(
Invoice,
id=invoice_id,
tenant=request.tenant
)
Vulnerability 2: Mass Assignment
# ❌ VULNERABLE: Client can set tenant_id
def create_invoice(request):
data = request.data # {"tenant_id": 123, "amount": 100}
invoice = Invoice.objects.create(**data) # Client controls tenant!
return JsonResponse(invoice.to_dict())
# ✅ SECURE: Never trust client for tenant
def create_invoice(request):
data = request.data
data.pop('tenant_id', None) # Remove if present
invoice = Invoice.objects.create(
tenant=request.tenant, # From auth, not client
**data
)
return JsonResponse(invoice.to_dict())
Vulnerability 3: GraphQL/API Field Exposure
# ❌ VULNERABLE: Exposes tenant_id in API
class InvoiceSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = ['id', 'tenant_id', 'amount', 'customer']
# Client can see and potentially modify tenant_id
# ✅ SECURE: Don't expose tenant_id
class InvoiceSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = ['id', 'amount', 'customer']
read_only_fields = ['id']
# tenant_id not included
Vulnerability 4: JWT Token Tampering
# ❌ VULNERABLE: Trust client-provided tenant
@api_view(['GET'])
def get_data(request):
tenant_slug = request.headers.get('X-Tenant') # Client controlled!
tenant = Tenant.objects.get(slug=tenant_slug)
# ...
# ✅ SECURE: Tenant from JWT claims
@api_view(['GET'])
def get_data(request):
# Tenant extracted during authentication
tenant = request.tenant # From JWT, verified by backend
# ...
Testing Tenant Isolation
Test 1: Cross-Tenant Data Access
# tests/test_tenant_isolation.py
import pytest
from apps.accounting.models import Invoice
@pytest.mark.django_db
def test_users_cannot_access_other_tenants_invoices(
client, user1, user2, tenant1, tenant2
):
"""User from tenant1 cannot access tenant2's invoices"""
# Create invoice for tenant1
invoice1 = Invoice.objects.create(
tenant=tenant1,
amount=100,
number="INV-001"
)
# Create invoice for tenant2
invoice2 = Invoice.objects.create(
tenant=tenant2,
amount=200,
number="INV-002"
)
# Authenticate as tenant1 user
client.force_authenticate(user=user1)
# Try to access tenant1's invoice - should succeed
response = client.get(f'/api/invoices/{invoice1.id}/')
assert response.status_code == 200
assert response.data['number'] == 'INV-001'
# Try to access tenant2's invoice - should fail
response = client.get(f'/api/invoices/{invoice2.id}/')
assert response.status_code == 404 # Not found (tenant mismatch)
# List endpoint should only return tenant1's invoices
response = client.get('/api/invoices/')
assert response.status_code == 200
assert len(response.data['results']) == 1
assert response.data['results'][0]['id'] == invoice1.id
Test 2: Tenant in URL vs Authentication
@pytest.mark.django_db
def test_tenant_url_must_match_auth(client, user1, tenant1, tenant2):
"""Tenant in URL must match authenticated user's tenant"""
# Authenticate as tenant1 user
client.force_authenticate(user=user1)
# Try to access tenant2's URL
response = client.get(f'/api/{tenant2.slug}/invoices/')
# Should fail with 403 or redirect
assert response.status_code in [403, 404]
Test 3: Manager Filtering
@pytest.mark.django_db
def test_manager_filters_by_tenant(tenant1, tenant2):
"""Manager automatically filters by current tenant"""
# Create invoices for different tenants
invoice1 = Invoice.objects.create(tenant=tenant1, amount=100)
invoice2 = Invoice.objects.create(tenant=tenant2, amount=200)
# Set tenant context
with tenant_context(tenant1):
# Should only see tenant1's invoices
invoices = Invoice.objects.all()
assert invoices.count() == 1
assert invoices.first().id == invoice1.id
with tenant_context(tenant2):
# Should only see tenant2's invoices
invoices = Invoice.objects.all()
assert invoices.count() == 1
assert invoices.first().id == invoice2.id
Automated Security Checks
Pre-Deployment Checklist
# 1. Check all tenant pages have authentication
yarn check:page-auth
# 2. Run tenant isolation tests
docker compose exec backend pytest -m tenant_isolation
# 3. Check for hardcoded tenant IDs
grep -r "tenant_id.*=" apps/ | grep -v "request.tenant"
# 4. Verify API endpoints filter by tenant
grep -r "objects.all()" apps/*/api/views/ | grep -v "filter(tenant"
Automated Test Script
# scripts/test_tenant_security.py
"""
Automated tenant security tests
Run: python manage.py shell < scripts/test_tenant_security.py
"""
from django.contrib.auth.models import User
from apps.tenants.models import Tenant
from apps.accounting.models import Invoice
print("🔒 Testing Tenant Security\n")
# Create test tenants
tenant1 = Tenant.objects.create(name="Tenant 1", slug="tenant1")
tenant2 = Tenant.objects.create(name="Tenant 2", slug="tenant2")
# Create test data
invoice1 = Invoice.objects.create(tenant=tenant1, amount=100)
invoice2 = Invoice.objects.create(tenant=tenant2, amount=200)
# Test 1: Query without tenant filter
print("Test 1: Unfiltered queries...")
all_invoices = Invoice.objects.all()
if all_invoices.count() == 2:
print("⚠️ WARNING: Queries not filtered by tenant")
else:
print("✅ Queries properly filtered")
# Test 2: Can access cross-tenant data?
print("\nTest 2: Cross-tenant access...")
with tenant_context(tenant1):
tenant1_invoices = Invoice.objects.all()
if invoice2 in tenant1_invoices:
print("❌ FAIL: Can access other tenant's data!")
else:
print("✅ PASS: Proper tenant isolation")
# Cleanup
tenant1.delete()
tenant2.delete()
print("\n✅ Security tests complete")
Common Mistakes
Mistake 1: Forgetting Tenant Filter
# ❌ WRONG
def get_recent_invoices():
return Invoice.objects.all()[:10] # Returns ALL tenants' invoices!
# ✅ CORRECT
def get_recent_invoices(tenant):
return Invoice.objects.filter(tenant=tenant).all()[:10]
Mistake 2: Using Primary Keys in URLs
# ❌ RISKY: Sequential IDs are guessable
/api/invoices/1234/
# ✅ BETTER: Use UUIDs
/api/invoices/a1b2c3d4-e5f6-7890-abcd-ef1234567890/
# In model:
import uuid
class Invoice(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
Mistake 3: Tenant in JWT but Not Used
# ❌ WRONG: JWT has tenant but code doesn't use it
def get_invoices(request):
# JWT contains tenant claim
# But query doesn't use it
return Invoice.objects.all()
# ✅ CORRECT: Extract and use tenant from JWT
def get_invoices(request):
tenant = request.tenant # From JWT claims
return Invoice.objects.filter(tenant=tenant)
OAuth Flows and Tenant Context
CRITICAL: OAuth flows MUST preserve tenant context throughout the entire authentication process.
The Problem
When users authenticate via OAuth (Auth0, Google, etc.), the redirect flow can lose tenant context:
# ❌ WRONG: Context lost during OAuth redirect
def oauth_callback(request):
# User authenticated but... which tenant?
user = authenticate_oauth(request)
return redirect('/error') # No tenant context!
Required Pattern: Preserve Tenant in OAuth State
# ✅ CORRECT: Include tenant in OAuth state parameter
def initiate_oauth(request, tenant_slug):
state = {
'tenant_slug': tenant_slug,
'csrf_token': generate_csrf_token(),
'redirect_uri': request.GET.get('redirect_uri', f'/{tenant_slug}/dashboard')
}
encoded_state = base64.urlsafe_b64encode(json.dumps(state).encode()).decode()
oauth_url = f"{AUTH0_DOMAIN}/authorize?" + urlencode({
'client_id': AUTH0_CLIENT_ID,
'redirect_uri': CALLBACK_URL,
'state': encoded_state,
'response_type': 'code',
'scope': 'openid profile email',
})
return redirect(oauth_url)
def oauth_callback(request):
# Extract tenant from state
encoded_state = request.GET.get('state')
state = json.loads(base64.urlsafe_b64decode(encoded_state).decode())
tenant_slug = state['tenant_slug']
# Verify CSRF
if state['csrf_token'] != get_session_csrf_token(request):
return redirect(f'/{tenant_slug}/error?message=csrf_validation_failed')
try:
user = authenticate_oauth(request)
# Redirect back to tenant-specific URL
return redirect(state['redirect_uri'])
except OAuthError as e:
# ✅ Preserve tenant context in error redirect
return redirect(f'/{tenant_slug}/error?message={e.message}')
Frontend OAuth (Next.js + Auth0)
// pages/[tenant]/login.tsx
import { useAuth0 } from "@auth0/auth0-react";
import { useRouter } from "next/router";
function LoginPage() {
const { loginWithRedirect } = useAuth0();
const router = useRouter();
const { tenant } = router.query;
const handleLogin = () => {
loginWithRedirect({
// Include tenant in appState - Auth0 preserves this through redirect
appState: {
returnTo: `/${tenant}/dashboard`,
tenant: tenant,
},
});
};
return <button onClick={handleLogin}>Login</button>;
}
// Auth0 callback handler
// _app.tsx or auth provider
const onRedirectCallback = (appState) => {
const tenant = appState?.tenant;
const returnTo = appState?.returnTo || `/${tenant}/dashboard`;
// Always redirect to tenant-specific URL
router.push(returnTo);
};
Error Handling with Tenant Context
# ✅ CORRECT: All error paths preserve tenant
def oauth_callback(request):
tenant_slug = extract_tenant_from_state(request)
try:
# ... authentication logic
except OAuthError as e:
logger.error(
"OAuth error",
extra={
'tenant': tenant_slug,
'error': str(e),
'user_agent': request.META.get('HTTP_USER_AGENT'),
}
)
return redirect(f'/{tenant_slug}/auth/error?code={e.code}')
except Exception as e:
logger.exception(
"Unexpected OAuth error",
extra={'tenant': tenant_slug}
)
return redirect(f'/{tenant_slug}/auth/error?code=unexpected')
Testing OAuth Tenant Preservation
@pytest.mark.django_db
def test_oauth_preserves_tenant_on_error(client, tenant):
"""OAuth errors must redirect to tenant-specific error page"""
# Simulate OAuth callback with invalid code
response = client.get(
'/auth/callback',
{'code': 'invalid', 'state': encode_state({'tenant_slug': tenant.slug})}
)
# Should redirect to tenant error page, not generic /error
assert response.status_code == 302
assert f'/{tenant.slug}/auth/error' in response.url
@pytest.mark.django_db
def test_oauth_state_includes_tenant(client, tenant):
"""OAuth initiation must include tenant in state"""
response = client.get(f'/{tenant.slug}/auth/login')
redirect_url = response.url
# Extract state from OAuth URL
parsed = urlparse(redirect_url)
state = parse_qs(parsed.query)['state'][0]
decoded_state = json.loads(base64.urlsafe_b64decode(state))
assert decoded_state['tenant_slug'] == tenant.slug
Emergency Response: Tenant Data Leak
If tenant data is accidentally exposed:
# 1. Immediately revoke access
# Rotate JWT secrets
export JWT_SECRET=$(openssl rand -base64 32)
# Force all users to re-login
# 2. Audit data access logs
docker compose exec backend python manage.py shell
>>> from apps.audit.models import AccessLog
>>> suspicious = AccessLog.objects.filter(
... tenant_mismatch=True
... )
>>> print(f"Suspicious accesses: {suspicious.count()}")
# 3. Notify affected tenants
# Send security notification emails
# 4. Fix vulnerability and deploy
# Add tests to prevent recurrence
Summary Checklist
For Every New Feature:
- All queries filter by
tenant=request.tenant - All API endpoints authenticated
- No client-provided tenant IDs trusted
- Cross-tenant access tests written
- UUIDs used instead of sequential IDs
- Tenant pages have
withAuthenticationRequired - Run
yarn check:page-auth - Run tenant isolation tests
For Every Deployment:
- Review all tenant-related changes
- Run full test suite including tenant tests
- Check for hardcoded tenant IDs
- Verify authentication on all new pages
Multi-tenant security is not optional - it's the foundation of the entire system.