All Articles
Security

Rate Limiting, Token Blacklisting, and Admin MFA — Auth Hardening for Tabeer.ai

Shahid MalikBy Shahid MalikSeptember 5, 202610 min read

Part two of the production-readiness checklist: authentication and account security. Five of fifteen items were genuinely missing — rate limiting, real logout, admin MFA, consent records, and self-service data export/deletion — and here's exactly how each got fixed without a new dependency for most of them.

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.

Related Articles

Security

The Encryption Checklist for a Small Django Production Stack

Thirteen encryption checks, framed as 'encryption at multiple layers, not just HTTPS.' Most of the layers already existed — TLS to Postgres, client-side-encrypted backups — one item doesn't apply to this architecture at all, and two needed an AWS console check I couldn't run myself.

7 min read
Shahid Malik - AI-First Odoo Consultant

Shahid Malik

AI-First Odoo ERP Specialist

Shahid Malik is an AI-first Odoo consultant helping businesses solve complex ERP and business process challenges. His work combines Odoo consulting, process optimization, automation, integrations, migrations, and practical AI solutions to build scalable and reliable business systems.

Book a consultation for your Odoo project
Discuss Your Odoo Project