Using the Strangler Fig pattern for auth migration
The Strangler Fig pattern is a software architectural strategy for incrementally migrating legacy systems by gradually replacing specific functionalities with new services or applications. A proxy layer (façade) intercepts requests, routing them between the old system and new services, allowing both to coexist until the old system is completely replaced. However, one of the least talked about usages of this system is in security. Sometime back, we worked on a project where we implemented this design principle to migrate a complex and unmanageable auth service to a new manageable service. In this blog, I will describe how we achieved it. To keep it simple, we will assume a bare-minimum Django REST framework. The original service was much more complex.
Problem Statement
1. Per-view decorators and copy-pasted rules
Login and authorization were enforced per view with decorators and copy-pasted conditionals. Basically, every function required developers to add a login_required decorator.
Security impact: As this was not enforced by default, developers would sometimes miss this check, because they assumed it was handled at the framework level, or they forgot about it entirely. This led to authentication issues that were sometimes caught only during pentests or incidents.
Before (example):
# views.py: auth is manual on every class; rules are duplicated, not composed
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from rest_framework.views import APIView
from rest_framework.response import Response
class UserProfileView(APIView):
@method_decorator(login_required)
def get(self, request, pk):
if int(pk) != request.user.id:
return Response(status=403)
return Response(...)
class UserOrdersView(APIView):
@method_decorator(login_required)
def get(self, request):
# Same file, “same” feature area, but the object-level rule never got copied here
return Response(load_orders_for_user(request.user.id))
2. Admin vs non-admin not structurally separated
Admin vs non-admin routes/APIs lived in the same modules and URL lists. Reviewers had to infer intent from view bodies to understand whether it was meant to be an admin API or a normal API.
Security impact: A single decorator mistake gave privileged admin behavior to ordinary APIs (vertical privilege escalation by accident).
Before (example):
# urls.py: privileged and routine APIs are wired from the same table
urlpatterns = [
path("api/me/", MeView.as_view()),
path("api/orders/", OrderListView.as_view()),
path("api/reports/company/", CompanyReportView.as_view()),
]
# views.py: “admin” is just another view next to user views
class CompanyReportView(APIView):
def get(self, request):
if not request.user.groups.filter(name="Admins").exists():
return Response(status=403)
return Response(load_company_wide_metrics())
3. No default deny or standard pattern for new endpoints
Nothing at the framework level required new views to opt in to auth. Teams relied on humans remembering decorators or permission_classes for each addition.
Security impact: new APIs can go live effectively unauthenticated or over-broad until someone notices in traffic or an incident: classic “forgot the decorator” data exposure.
Before (example):
# settings.py: no global default; anonymous is the accidental baseline for DRF views
REST_FRAMEWORK = {
# DEFAULT_PERMISSION_CLASSES omitted on purpose in the legacy setup
}
# views.py: new endpoint ships in a hurry
class PartnerIntegrationProbeView(APIView):
def get(self, request):
return Response(internal_debug_payload())
Target end state
- Clear split between admin and non-admin surfaces (URL namespaces, separate
urlpatterns, or separate apps). - No “remember to add
@logged_in” on each function: authentication is implied by base classes or global defaults, and each view only declares what extra permission it needs. - Least privilege via explicit
permission_classes(and small, composable permission classes), not one-offifbranches that re-implement admin access inside handlers.
Implementation
Step 1: Decide the pattern
Concretely for Django REST framework (DRF):
| Surface | Base permission | Extra |
|---|---|---|
| Authenticated user APIs | IsAuthenticated |
Resource-specific classes (e.g. IsOwner, HasWriteScope) |
| Admin APIs | IsAuthenticated, IsAdminUser (or custom IsCompanyAdmin) |
None beyond that unless you need finer roles |
Name apps or URL prefixes so code review is obvious: api/v1/... vs api/admin/v1/....
Carry that split into separate URL modules for user vs admin traffic. Admin-only views should declare IsAdminUser (or your RBAC equivalent) on the class, not with ad hoc admin if checks inside the handler, so reviewers can line up “this path prefix” with “these permission classes.”
# apps/api/urls.py: user-facing API
from django.urls import path
from .views import MeView, OrderListView
urlpatterns = [
path("me/", MeView.as_view()),
path("orders/", OrderListView.as_view()),
]
# apps/api/admin_urls.py: mounted only under an admin prefix
from django.urls import path
from .views import CompanyReportView
urlpatterns = [
path("reports/company/", CompanyReportView.as_view()),
]
# project/urls.py
from django.urls import include, path
urlpatterns = [
path("api/", include("apps.api.urls")),
path("api/admin/", include("apps.api.admin_urls")),
]
# apps/api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAdminUser
class CompanyReportView(APIView):
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
return Response(load_company_wide_metrics())
Step 2: Centralize “logged in” on permission_classes
Before: decorators on every view.
After: one default for the majority of routes, overrides only where anonymous access is intentional. With DEFAULT_PERMISSION_CLASSES set to IsAuthenticated, new views inherit “logged in” without boilerplate; only intentionally public endpoints set AllowAny (and should be rare and reviewed).
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
# apps/api/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
class UserOrdersView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(load_orders_for_user(request.user.id))
class PartnerIntegrationProbeView(APIView):
def get(self, request):
# Omits permission_classes → uses DEFAULT_PERMISSION_CLASSES (authenticated)
return Response(internal_debug_payload())
class PublicHealthView(APIView):
permission_classes = [AllowAny]
def get(self, request):
return Response({"status": "ok"})
For anonymous endpoints (health, public docs, webhook signature verification), set permission_classes = [AllowAny] on that view only.
Legacy views that still use decorators can remain behind the strangler until ported; the goal is that all new code uses the same permission surface.
Step 3: Enforce least privilege per API
Replace broad inline admin checks inside methods with named permission classes so the contract is visible on the class and testable.
# apps/api/permissions.py
from rest_framework.permissions import BasePermission
class IsOrderOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.user_id == request.user.id
class IsTargetUserSelf(BasePermission):
"""For plain APIView + `pk` in the URL; no copy-pasted `if int(pk) != request.user.id` in each handler."""
def has_permission(self, request, view):
pk = view.kwargs.get("pk")
return pk is not None and int(pk) == request.user.id
# apps/api/views.py
from rest_framework.permissions import IsAuthenticated
from .permissions import IsOrderOwner
class OrderDetailView(APIView):
permission_classes = [IsAuthenticated, IsOrderOwner]
def get(self, request, pk):
...
from rest_framework.permissions import IsAuthenticated
from .permissions import IsTargetUserSelf
class UserProfileView(APIView):
permission_classes = [IsAuthenticated, IsTargetUserSelf]
def get(self, request, pk):
return Response(...)
Admin-only mutations get IsAdminUser (or your RBAC equivalent) at the class level. For route layout, pair that with the admin URL module from Step 1 instead of burying admin checks in post().
Step 4: Strangler facade, traffic, logs, cutover
Introduce a facade that fronts the Django app (reverse proxy, API gateway, or internal load balancer):
- Route all traffic through the facade first. Point the same hostname to the facade; the facade forwards to the current Django deployment unchanged until you are ready.
- Incrementally enable the new stack or new URL mount (e.g.
/v2/served by refactored URLs, or a separate ASGI worker group) while/v1/or legacy paths still hit old code paths if needed. - Log at the facade and in Django:
user_id,path,method, permission outcome (403vs200), and correlation id. Use logs to confirm you are not seeing unexpected403spikes or missing auth on migrated routes. - When metrics match expectations, remove the legacy branch (old decorators-only views or duplicate URLconf) and point the facade directly at the unified app.
The strangler buys time to prove the new permission model without a single risky flag day.
PR-level guardrails
Once defaults exist, block regressions:
- Lint / static check: fail CI if a new
APIView/ViewSetsubclass does not setpermission_classeswhen it lives under certain paths (e.g.api/), or if it setspermission_classes = []without an allowlisted exception file. - OpenAPI / schema diff: require security schemes on every operation for published APIs.
- Optional: a codemod or
grep-based test thaturls.pyentries underapi/admin/only import views from anadmin_viewsmodule that always includesIsAdminUserin a shared base.
Example CI idea (conceptual): a script that walks urlpatterns, imports the view class, and asserts permission_classes is non-empty or inherits from your BaseAuthenticatedView.
Summary
| Phase | Action |
|---|---|
| Design | Admin vs non-admin layout; default authenticated; AllowAny only where intended |
| Migrate | Move from decorators to DEFAULT_PERMISSION_CLASSES + explicit permission_classes |
| Harden | Small permission classes per resource/action instead of inline checks |
| Strangle | Facade + incremental traffic + structured logs → delete legacy enforcement |
| Sustain | CI rules so new routes cannot skip the pattern |
The Strangler Fig pattern here is not about swapping microservices. It is about moving authorization to a single, reviewable layer in Django, proving it in production, then deleting the unmaintainable spread of per-endpoint auth logic.