6 min read

Inheritance-Based Auth: Where It Breaks

In codebases, using secure inheritance checks to enforce authentication and authorization at scale is one of the most trusted approaches. Apply checks on the base class, and everything that inherits from it is protected. However, almost every major framework has gaps where that inheritance breaks down, causing authentication and authorization vulnerabilities at scale (Authentication bypass, MFA bypass and privilege escalation). This happens because we assume that everything in our API path inherits from the base classes, but we end up forgetting that there are some paths that don't inherit from the base classes. This post highlights exactly where that gap appears in major frameworks and how to find (or fix) it.

Framework deep dives: where the inheritance breaks down

Django: the class-based vs function-based view split

In Django, the two main ways to define views are class-based views (CBVs) and function-based views (FBVs). They use completely different mechanisms for applying behavior.

Inheritance-based auth (CBVs):

# views/base.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView

class SecureBaseView(LoginRequiredMixin, TemplateView):
    """All our pages require login."""
    pass

Any view that inherits from SecureBaseView gets LoginRequiredMixin and redirects unauthenticated users to the login page. So far, so good.

The gap: LoginRequiredMixin is part of the class-based view lifecycle. It hooks into dispatch() and similar methods. Function-based views don't use that lifecycle at all. They're just Python functions. The only way to protect them is to use a decorator:

# views/legacy.py or views/api.py
from django.shortcuts import render
from django.contrib.auth.decorators import login_required

@login_required  # If this is missing, the view is public.
def internal_dashboard(request):
    return render(request, "dashboard.html", {"data": get_sensitive_data()})

If the codebase standard is "we use LoginRequiredMixin on our base view," it's easy to assume "all views are protected." But any URL that points to a function without @login_required is unprotected. Common scenarios:

  • Legacy or "quick" endpoints written as FBVs.
  • API-style endpoints added before the team standardized on CBVs.
  • Views in a different app that weren't updated when the "secure base" was introduced.

What to look for: Search for urlpatterns and path()/re_path() entries. For each view, ask: is it a class (CBV) or a callable (FBV)? If it's a function, does it have @login_required (or equivalent)? If the project has a SecureBaseView (or similar) but also has FBVs, treat every FBV as potentially missing auth until proven otherwise.

Django REST framework: permission_classes and MFA

The same split appears in DRF. Auth and authorization are often enforced on a base class via permission_classes (and sometimes MFA via a custom permission or mixin):

# api/views/base.py
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated

class SecureBaseAPIView(APIView):
    permission_classes = [IsAuthenticated]  # or [IsAuthenticated, RequireMFA]
    # Custom MFA check might live here or in a mixin.

Every APIView or ModelViewSet that inherits from SecureBaseAPIView gets those permission checks. The gap: DRF also supports function-based API views with the @api_view decorator. Those do not inherit from your base class, so they never get permission_classes or any MFA logic:

# api/views/legacy.py
from rest_framework.decorators import api_view
from rest_framework.response import Response

@api_view(["GET"])  # No permission_classes here; no inheritance from SecureBaseAPIView.
def internal_report_list(request):
    return Response(get_sensitive_reports())  # Intended to be authenticated + MFA; neither runs.

If the project standard is "all API views inherit from SecureBaseAPIView," any route that uses @api_view without explicitly setting permission_classes (e.g. via a decorator or wrapper) is unprotected. Same for MFA: if MFA is enforced only on the base class or a mixin used by class-based views, function-based @api_view endpoints skip it entirely.

What to look for: Grep for @api_view and for permission_classes on base API views. For every @api_view, confirm it has explicit permission checks (e.g. @permission_classes([IsAuthenticated]) or a decorator that enforces MFA). If the codebase has a "secure" base API view with permission_classes (and/or MFA), treat every @api_view as a potential auth/MFA bypass until proven otherwise.


Rails: ApplicationController vs Metal (and API-only stacks)

In Rails, the standard place for global auth is ApplicationController:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :check_mfa, if: :mfa_required?
  # ...
end

Every controller that inherits from ApplicationController runs these callbacks. The mental model is "everything under ApplicationController is protected."

The gap: Not every controller inherits from ApplicationController::Base. **ActionController::Metal** is a stripped-down base used for high-performance or API-only controllers. It intentionally omits much of the default stack, including the callback chain that runs before_action from a parent. So:

# app/controllers/api/v2/base_controller.rb
class Api::V2::BaseController < ActionController::Metal
  include ActionController::RackDelegation
  include ActionController::UrlFor
  # ... no ApplicationController, so no authenticate_user!
end

# app/controllers/api/v2/users_controller.rb
class Api::V2::UsersController < Api::V2::BaseController
  def index
    @users = User.all  # Intended to be admin-only; no auth runs.
  end
end

Here, authenticate_user! and check_mfa never run for Api::V2::UsersController, because its ancestor is Metal, not ApplicationController. So not only is auth skipped; any MFA enforced in check_mfa (or in a before_action higher in the chain) is skipped too. Metal-based controllers never run that callback, so sensitive actions can be reached without a second factor. The same can happen with API-only apps (e.g. rails new --api) or any controller that was deliberately taken "off" the full stack for performance or simplicity. If the team's security story is "we have before_action :authenticate_user! in ApplicationController," those Metal-based controllers are outside that story.

What to look for: Grep for ActionController::Metal (or ActionController::API in API-only apps) and trace the inheritance of every controller that uses it. Confirm that auth and authorization are explicitly applied on that branch (e.g. via a shared concern or a base that includes the same before_action). If they're not, every action on that controller is a candidate for auth/MFA bypass or privilege escalation.


ASP.NET Core: controllers vs Minimal APIs

In ASP.NET Core, the classic approach is controllers with attributes:

// Controllers/BaseController.cs
[Authorize]
[RequireMfa]  // Or a custom filter that enforces two-factor.
public abstract class BaseController : ControllerBase
{
    // All derived controllers require authentication and MFA.
}

Any controller that inherits from BaseController gets [Authorize] and any MFA requirement; they're protected by the authentication middleware and authorization policies.

The gap: Minimal APIs (introduced in .NET 6) don't use controllers at all. Routes are registered with MapGet, MapPost, and similar:

// Program.cs or a dedicated minimal API file
app.MapGet("/api/reports/{id}", (int id) => GetReport(id));
app.MapPost("/api/admin/users", (UserDto user) => CreateUser(user));

There is no controller class, no inheritance, and no [Authorize] or [RequireMfa] attribute. The pipeline for minimal APIs is separate; filters and attributes attached to controller base classes don't apply. So if the team's security model is "all our APIs are in controllers that inherit from BaseController," every minimal API endpoint is unprotected unless they explicitly call something like RequireAuthorization() (and, if the app enforces MFA on controllers, minimal APIs need an equivalent MFA check too):

app.MapGet("/api/reports/{id}", (int id) => GetReport(id))
   .RequireAuthorization();  // Without this, the endpoint is public.

In practice, teams often adopt Minimal APIs for new or refactored routes (simplicity, less boilerplate) while still thinking of security in terms of "our base controller." Those new routes are the ones most likely to lack explicit auth.

What to look for: Search for MapGet, MapPost, MapPut, MapDelete, and similar. For each minimal API endpoint, verify that authorization is explicitly required (e.g. .RequireAuthorization() or equivalent). Cross-check with any documentation or comments that say "all API routes require auth via BaseController"; then confirm that minimal APIs are not relying on that inheritance, because they can't.


The same pattern shows up in other ecosystems:

  • Express (Node): If "all routes go through authMiddleware" is enforced by mounting it on a router, any route registered on a different router (or directly on app) without that middleware is unprotected.
  • Spring (Java): Security is often applied via WebSecurityConfigurerAdapter or method security on a base class. If the team adds WebFlux functional endpoints or another style that doesn't go through the same security configuration, those can be out of scope.
  • Laravel (PHP): Middleware applied in a base controller or route group doesn't automatically apply to routes defined in another group or to closures registered without that group.

Why this keeps happening

  1. New features (Minimal APIs, Metal, FBVs used alongside CBVs) are added over time. Security was designed around the original model.
  2. Sometimes documentation does not highlight where we are setting security checks on base classes. So new developers and reviewers assume that all endpoints are automatically protected and they do not have to worry about it.
  3. Sometimes it is a one off approved API to support a feature, which later turns into a full blown project

What to do about it: checklist for reviewers and developers

If you're reviewing or hunting:

  1. List every way the app defines endpoints(e.g. CBVs, FBVs, Metal controllers, Minimal APIs, separate routers.)
  2. Identify the "secure base"(e.g. SecureBaseView, ApplicationController, BaseController.)
  3. For each endpoint-definition style, ask:"Does this style use that base (or the same middleware/filter chain)?" If not, it's a gap.
  4. Enumerate all endpoints in the gap(e.g. every FBV, every Metal controller action, every MapGet/MapPost.)
  5. Verify each of those has explicit auth/authorization(decorator, permission_classes, before_action, RequireAuthorization(), or equivalent.)
  6. Check MFA and role-based checksIf MFA or role/permission checks (e.g. permission_classes, [Authorize(Roles = "Admin")]) live only on the "main" path, the same gap can mean MFA bypass or privilege escalation on the alternate path.

If you're developing:

  1. Document how auth is applied per endpoint typee.g. "CBVs: inherit from SecureBaseView. FBVs: must use @login_required."
  2. Add tests or checkse.g. "All routes in urlpatterns that use a callable must have @login_required" or "All minimal API endpoints must call RequireAuthorization()."
  3. Onboard with the gap in mindMake it explicit: "We have two ways to define views; here's how auth works for each."

Conclusion

Inheritance-based authentication and authorization are a solid default, but they only protect the code that actually lives in that inheritance chain. As soon as the framework (or the team) introduces another way to define endpoints (function-based views, Metal controllers, Minimal APIs, or a separate router), that new path is out of scope for the base-class checks. Therefore, remember to audit your codebases for any such patterns and fix them before misuse.

Please subscribe if you would like to receive more content like this 😄