As our backend ecosystem grew at Aveti, one challenge kept showing up across services: authorization logic was getting duplicated and inconsistent.
We had multiple user types — students, admins, teachers, centers — each with different access rules. Every service needed to enforce them correctly and most users had access only to a specific set of courses, represented as a list of course IDs (with super-admins being the exception).
We started with simple implementations and iterated over time, gradually improving security, consistency, and performance as the system evolved.
APISIX as an Authorization Wall (Semi-Trusted Zone)
We use Apache APISIX as our API gateway, so it was tempting to centralize authorization at the gateway level before forwarding requests to backend services.
Thanks to APISIX’s plugin system, we implemented a custom Lua plugin that:
- Validates the incoming JWT
- Extracts the user type and user ID
- Injects them into request headers (X-User-Id, X-User-Type)
Course access was handled by calling our subscription service (using an internal API key) to fetch the list of course IDs for the user. These IDs were also added to the request as headers and cached in Redis with a short TTL. When course assignments changed, the cache was invalidated by the subscription service.
Since our services were not directly exposed to the internet, all external traffic flowed through APISIX. This meant there was no supported path for requests to reach services without passing through the gateway.
On the service side, things stayed simple. We used a set of FastAPI dependency functions to extract the user ID, user type, and course IDs from the request context and enforce authorization where needed.
A Nice Side Effect: Better Observability
An unexpected but very valuable benefit of setting user identifiers early in the request lifecycle was observability.
Because user ID and user type were already present as headers, we could include them in traces and logs across services. Debugging suddenly became much easier — not just for bugs, but also for understanding user behavior, common UX confusions, and “why is this user clicking this five times?” moments.
Hardening the Design: Removing Implicit Trust
After things started working, we realized this might not be “a most secure” design approach to just left the service endpoints open and assuming the gateway, network and nodes would never be bypassed. Honestly, I didn’t really have trust issues but I knew Murphy’s Law:
“anything that can go wrong, will go wrong”
So we added JWT validation at the service level as well. Dependency functions like get_user_id and get_user_type now extract identity directly from the token instead of trusting HTTP headers., ensuring requests are still rejected even if they reach a service through an unexpected path
The gateway still plays an important role — but it’s no longer a single point of trust.
A Shared Authorization Module to the Rescue
As new features were added and new services were born, we found ourselves duplicating authorization code from one repository to another. To be honest, copying the code wasn’t even the hard part.
The real pain started when we needed to introduce a new user type. Suddenly, the same logic had to be updated in multiple services, manually, and in exactly the same way. Unsurprisingly, this didn’t scale very well.
At that point, we paused and re-imagined how authorization should work across services.
We ended up creating a shared Python module responsible for validating different user types and exposing a simple, consistent interface. Instead of sprinkling role checks throughout the codebase, services could rely on dependency functions such as:
get_admin()— when an endpoint should only be accessible to adminsget_user()— when any authenticated user is allowedget_user_or_anonymous()— when anonymous access is also acceptable
These dependencies return a simple User object containing everything an API route needs: user ID, user type, permissions (for admins), and other relevant context. This kept application code clean and avoided conditional checks like if user != admin scattered across endpoints.
The module is installed during image build and used consistently across all services. Now, when a new user type is introduced, we only need to update this centralized module instead of hunting down logic in multiple repositories.
Moving Course Access into the Authorization Layer
Once authorization logic was centralized, another improvement became obvious.
Since this module was already responsible for building the user context, it made sense to move course subscription fetching there as well. Instead of doing this work at the gateway, the authorization module now fetches course access directly from the subscription service.
Because we were already using gRPC for inter-service communication, we switched to gRPC here as well instead of HTTP. This kept service-to-service communication consistent and reduced unnecessary protocol mixing.
🔐 Fine-Grained Admin Permissions (Without If-Else Hell)
While roles like student or teacher were usually enough, admin access turned out to be more nuanced.
Not every admin should be able to do everything.
So instead of adding more roles, we introduced permission-based authorization for admin users. Each admin permission is represented as a bit flag (for example: course creation, modification, student management, etc.), and these permissions are stored as a single integer inside the JWT — similar to how file permissions work in Linux.
This lets us answer questions like “does this admin have permission X?” using simple bitwise checks, without extra database calls.
Protecting Routes with a Decorator
To make this easy (and readable) at the API level, we wrapped the logic inside a decorator:
@admin_required(AdminPermission.SUPER_ADMIN) @admin_required(permission=AdminPermission.COURSE_MODIFY)
Under the hood, the decorator ensures:
- the request is coming from an authenticated admin
- the admin has the required permission (or is a super-admin)
Here’s the simplified implementation:
def admin_required(permission: AdminPermission): """ Decorator to protect API endpoints that require admin permissions. :param permission: The required permission. :return: The decorated function. """ def decorator(func): @wraps(func) async def wrapper(*args, admin: APIUser = Depends(get_admin), **kwargs): if ( not admin.permission & permission.value and not admin.permission & AdminPermission.SUPER_ADMIN.value ): raise HTTPException( status_code=403, detail="Forbidden: Insufficient permissions" ) return await func(*args, admin=admin, **kwargs) return wrapper return decorator
From the route handler’s point of view, this keeps things clean and declarative — no inline permission checks, no repeated boilerplate, and no accidental authorization bugs hiding inside business logic.
Press enter or click to view image in full size

High-level view of how authorization is enforced across services using a shared auth module.
Why This Worked Well for Us
- Permissions are compact and fast to evaluate
- JWTs carry everything needed for authorization
- Routes clearly document their access requirements
- Adding new permissions doesn’t require touching existing endpoints
This setup isn’t perfect, but it’s been reliable and easy to evolve which worked beautifully for us.
Curious to hear how others have approached similar problems.
