Part two of the Tabeer.ai production-readiness audit — same verify-first discipline, applied to a fifteen-item "Authentication & Account Security" section. Ten items were already correctly built (email verification blocking login, Django's own password hashing, single-use password-reset tokens, session expiration via JWT lifetimes). Five were real gaps. Here's each one, and exactly how it got closed.
Rate Limiting, Without a New Dependency
Login, registration, and password-reset had zero rate limiting — any of them could be hit as fast
as a script could send requests. Django REST Framework already ships a throttle system; no
django-ratelimit, no Redis-backed counter to wire up by hand:
REST_FRAMEWORK = {
...
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.ScopedRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"auth-login": "10/min",
"auth-register": "5/min",
"auth-password-reset": "5/hour",
},
}
ScopedRateThrottle only throttles a view that explicitly opts in via a throttle_scope
attribute — setting it as the default class doesn't touch any other endpoint in the app:
class LoginView(TokenObtainPairView):
serializer_class = EmailVerifiedTokenObtainPairSerializer
throttle_classes = [ScopedRateThrottle]
throttle_scope = "auth-login"
One deliberate choice: the limit is per-IP, not per-account. A per-account lockout sounds stricter, but it hands an attacker a free denial-of-service — three wrong passwords and they've locked a real user out of their own account. Throttling by IP slows down the actual attack (credential stuffing, password spraying) without giving anyone a lever to grief someone else's login.
The Test Suite Bug This Caused
Running the full suite immediately after wiring this up produced 15 failures and 3 errors, all
429 Throttled where a 201 Created or 400 Bad Request was expected. Django's test runner
doesn't reset the cache between test methods, and throttle counters live in the cache — five
different test classes hitting /register/ and /login/ across a single manage.py test run
shared one counter, and it tripped the 5/min registration limit partway through.
The fix is the same pattern already used for caching platform stats
earlier: clear the cache in setUp().
class RegisterViewTests(APITestCase):
def setUp(self):
cache.clear() # throttle counters persist across tests otherwise
self.url = reverse("register")
Five test classes needed this. Worth calling out on its own: a security feature that breaks CI is
exactly the kind of thing that gets --no-verify'd past in a hurry and then never revisited.
Fixing the actual test isolation issue took ten minutes; skipping it would have left throttling one
skipped CI run away from silently regressing.
Logout That Actually Logs Out
Before this, "logout" was purely a client-side concept — the frontend deleted its stored tokens, but the refresh token itself stayed valid on the server until it naturally expired. A captured refresh token (XSS, a shared computer, a synced browser session) kept working after the user thought they'd signed out.
djangorestframework-simplejwt ships a blacklist app for exactly this — it just wasn't installed:
INSTALLED_APPS = [
...
"rest_framework_simplejwt.token_blacklist",
]
SIMPLE_JWT = {
...
"BLACKLIST_AFTER_ROTATION": True,
}
class LogoutView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
refresh = request.data.get("refresh")
if not refresh:
return Response({"detail": "refresh token is required."}, status=400)
try:
RefreshToken(refresh).blacklist()
except TokenError:
pass # already invalid — the end state the caller wanted is already true
return Response({"detail": "Logged out."})
The account-deletion endpoint (below) reuses the same mechanism to revoke every outstanding token for a user, not just the one making the request — a deleted account shouldn't stay usable from a different device that happened to still be logged in.
Admin MFA — Infrastructure Now, Enforcement Later
This one needed the most care, because getting the sequencing wrong locks out production admin
access. django-otp's OTPAdminSite requires request.user.is_verified() — true only if the
user has a confirmed TOTP device. Flip that on for an admin who hasn't enrolled a device yet,
and they can't log into their own admin panel at all, with no back door.
The fix ships in two deliberately separate stages:
# settings.py
INSTALLED_APPS = [
...
"django_otp",
"django_otp.plugins.otp_totp",
"django_otp.plugins.otp_static",
]
MIDDLEWARE = [
...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware", # right after AuthenticationMiddleware
...
]
# Defaults OFF — see accounts/admin.py
ADMIN_OTP_REQUIRED = env.bool("ADMIN_OTP_REQUIRED", default=False)
# accounts/admin.py
if settings.ADMIN_OTP_REQUIRED:
from django_otp.admin import OTPAdminSite
admin.site.__class__ = OTPAdminSite
Adding django_otp.plugins.otp_totp to INSTALLED_APPS is enough on its own to get a working
enrollment UI — the app auto-registers TOTPDevice in Django admin, QR code included, no custom
view needed. Staff can go create and confirm a device today, with zero change in what's actually
enforced. Only once every current staff account has a confirmed device does ADMIN_OTP_REQUIRED
flip to True — a one-line env var change, made deliberately after the fact rather than bundled
into the same deploy as the infrastructure.
Alongside this: the admin moved off the default /admin/ path to an app-specific
ADMIN_URL_PATH. Not a substitute for real access control — just removing the cheapest,
most automated class of traffic (scanners and credential-stuffing bots that specifically target
Django's well-known default path) before MFA ever gets a chance to matter.
Consent Is Recorded, Not Assumed
Registration didn't record when, or whether, a user had agreed to the Terms of Use — the frontend didn't even show a checkbox. Fixed as a required, validated field, not an implied default:
class RegisterSerializer(serializers.ModelSerializer):
terms_accepted = serializers.BooleanField(write_only=True)
def validate_terms_accepted(self, value):
if not value:
raise serializers.ValidationError("You must accept the Terms of Use and Privacy Policy to register.")
return value
def create(self, validated_data):
...
user = User(**validated_data, terms_accepted_at=timezone.now())
The boolean itself is write-only and disposable — what matters afterward is the resulting
timestamp, a real record of when consent happened, not just a flag that could mean anything.
The frontend registration form got an actual checkbox linked to /terms and /privacy, disabling
the submit button until it's checked.
Data Export and Real Deletion
Before this, "delete my account" meant emailing support, and there was no self-service way to get a copy of your own data at all. Two new endpoints:
class DataExportView(APIView):
"""Deliberately scoped to personal/identifying data — profile + notification
preferences — not a full activity-history dump."""
def get(self, request):
return Response({
"profile": UserSerializer(request.user).data,
"account": {"date_joined": ..., "terms_accepted_at": ...},
"notification_preferences": ...,
})
Deletion is the more interesting design decision. Tabeer.ai already had a reversible
DeactivateAccountView (flips is_active, sends a reactivation email). Real deletion needed to
be genuinely different — one-way, and password-confirmed so a session left open on a shared
computer can't be used to erase an account outright:
class DeleteAccountView(APIView):
def post(self, request):
user = request.user
if not user.check_password(request.data.get("password", "")):
return Response({"detail": "Incorrect password."}, status=400)
user.username = f"deleted-user-{user.pk}"
user.email = f"deleted-user-{user.pk}@deleted.tabeer.ai"
user.first_name = user.last_name = user.phone = ""
# ...every other identifying field...
user.is_active = False
user.deleted_at = timezone.now()
user.set_unusable_password()
user.save()
This anonymizes rather than hard-deletes the row. Tabeer.ai's users have real relational
history — test attempts, leaderboard entries, referral rewards other accounts point to. A hard
DELETE would either cascade into deleting other people's history too, or leave dangling
foreign keys, depending on how each relation's on_delete is configured. Scrubbing the personal
fields achieves the actual legal and ethical goal — this person's identifying data no longer
exists anywhere in the system — without corrupting data that isn't theirs to take with them.
What Verifying First Actually Bought
Same lesson as part one: checking each item against the real, running code before touching anything meant five focused fixes instead of fifteen speculative ones, a test suite that still passes at 684 tests, and a deploy check that stays clean — rather than a pile of half-verified changes made because a checklist said so.
If you're working through a similar audit and want a second pass to separate the real gaps from the noise, get in touch.
By Shahid Malik