Layered Authorization in Elysia.js: Why Your Middleware Is Doing Too Much

Most authorization systems are a single wall.
One middleware. One check. One decision: in or out.
That works until your product gets real. Until you have three different user roles, resources that belong to specific users, actions that depend on billing state, and permissions that shift based on the context of the request itself.
Then your single wall becomes a thousand-line monster with nested if-else chains so deep you need a debugger just to read them.
We threw that wall away. In its place, we built something better — a system of concentric rings, each one adding a thin layer of context, each one narrowing what the request can do.
This is the story of layered authorization in Elysia.js. And it changed how I think about security architecture forever.
I – The Monolith That Almost Ate Us
When we first launched mentoring.oakoliver.com, our auth middleware was one function.
It checked the session cookie. It decoded the JWT. It looked up the user. It checked the role. It verified resource ownership. It validated billing state.
All in one place.
And for the first few weeks, it worked. Mentors could see their dashboards. Mentees could browse profiles. Admins could do admin things.
Then the edge cases arrived.
A mentee trying to access a session that belonged to a different mentee. A mentor trying to modify billing for a session they weren't part of. An admin endpoint that should only be accessible to super admins, not regular admins.
Every new edge case meant another conditional branch in the same middleware. The function grew. The test suite grew faster. And every time someone touched that file, something else broke.
The problem wasn't the logic. The problem was the architecture. We were trying to make one function do the work of five.
II – The Mental Model: Concentric Rings
Think about how a medieval castle works.
The outer wall stops strangers. If you pass, you're in the courtyard — but you still can't enter the keep. Inside the keep, there's another locked door to the treasury. And inside the treasury, individual chests have their own locks.
Each ring of defense adds specificity. The outer wall doesn't care who you are — just that you're not an invader. The keep gate checks your rank. The treasury door checks your clearance. The chest checks whether you own the key.
That's exactly how derive chains work in Elysia.
Each derive adds context. Each scope narrows access. By the time the request reaches the handler, the authorization question has already been answered — not by one check, but by a chain of increasingly specific validations.
The handler never asks "can this user do this?" It already knows. The context tells it everything.
III – What Is derive() And Why It Changes Everything
If you've used Express, you're used to middleware that calls next(). The middleware runs, maybe attaches something to the request object, and passes control downstream.
Elysia's derive() is philosophically different.
It doesn't modify the request. It extends the context.
When you call derive(), you return an object. That object's properties become available to every handler and every subsequent derive in the chain. It's type-safe. The compiler knows exactly what's available at every point.
This is not a cosmetic difference. This is a structural difference.
In Express, you attach user data to a loosely-typed request object and pray that downstream middleware remembers the property name. In Elysia, the derive return type is part of the route's type signature. If you try to access a property that hasn't been derived yet, the compiler screams at you before the code ever runs.
The type system becomes your authorization documentation.
You don't need to read the middleware to know what context is available. The types tell you. And if someone removes a derive from the chain, every handler that depended on it lights up with errors immediately.
IV – The First Ring: Authentication
The outermost ring is the simplest. It answers one question: is this a real user?
Not what role they have. Not what they own. Not what they can do. Just: is the session valid?
This derive runs on every protected route. It reads the session cookie, validates the token, and returns the basic user object — ID, email, creation date. Nothing more.
The critical design decision: this layer returns the minimum viable identity.
It doesn't load roles. It doesn't load permissions. It doesn't load related resources. Those are concerns for deeper layers.
Why? Because many routes only need identity. A profile page needs to know who is viewing. A settings page needs to know who is saving. Loading the user's full role hierarchy, permission set, and resource ownership for these routes would be wasteful.
By keeping the first ring thin, every route that only needs identity gets it fast.
And here's the beauty of Elysia's scoping: this derive is defined at the top level, so it's available to every downstream route group. You define it once, and the type flows everywhere.
V – The Second Ring: Role Resolution
Once we know who the user is, the next question is: what kind of user are they?
In the mentoring platform, there are three roles — MENTOR, MENTEE, and ADMIN. But these aren't simple labels. Each role unlocks a different surface area of the application.
Mentors can manage their profiles, set hourly rates, view session history, and access earnings. Mentees can browse mentors, book sessions, manage credits, and leave reviews. Admins can see everything, moderate content, and manage platform settings.
The second derive layer resolves the user's role and returns a typed role object. Not a string. An object with properties specific to that role.
This is where most authorization systems go wrong. They check roles as strings, scattered across handlers. "If role equals MENTOR, do X. If role equals ADMIN, do Y." Every handler reimplements the same switch statement.
With derive chains, the role is resolved once. Downstream handlers receive a typed context that already encodes what the role can do. The handler doesn't check the role — it uses the role-specific context that was derived for it.
If a route group is mentor-only, the derive for that group resolves mentor-specific data and returns it. Mentee routes get mentee-specific context. Admin routes get admin context.
The role check isn't an if statement in a handler. It's a structural boundary in the route tree.
VI – The Third Ring: Resource Ownership
This is where it gets interesting.
Knowing that someone is a MENTOR doesn't mean they can access any mentor resource. A mentor can only view their own sessions, their own earnings, their own profile.
The third ring answers: does this user own this resource?
For session-related routes, this derive loads the session and verifies that the requesting user is a participant. For profile routes, it checks that the profile belongs to the authenticated user. For billing routes, it confirms the user is the payer or payee.
The pattern is always the same: load the resource, verify the relationship, return the scoped resource.
If the relationship doesn't exist, the derive short-circuits with a 403. The handler never runs. There's no possibility of accidentally processing a request for a resource the user doesn't own, because the derive chain stops it before it gets there.
This is a fundamentally different security posture than checking ownership inside the handler. When the check is in the handler, a developer can forget it. When the check is in the derive chain, the handler physically cannot execute without it.
You don't rely on developers remembering to check ownership. The architecture enforces it.
VII – The Fourth Ring: Contextual Permissions
Here's where layered authorization earns its keep.
Some permissions depend on state, not identity. A mentor can only start a session if the session status is PENDING. A mentee can only cancel a session if it hasn't started yet. An admin can only refund a session if the billing has been finalized.
These aren't role checks. These aren't ownership checks. These are state-dependent permissions that change based on the current condition of the resource.
The fourth derive layer examines the resource (which was loaded by the third ring) and determines what actions are currently valid.
It returns an object like: canStart, canCancel, canPause, canEnd, canRefund. Each is a boolean, computed from the session's current state.
Handlers don't compute permissions. They consume them.
A "start session" handler doesn't check whether the session status allows starting. It reads the canStart flag from context. If it's false, the derive already returned a 403.
This creates a beautiful separation: the derive chain knows the rules, the handlers know the actions. Neither needs to understand the other's concerns.
VIII – The Practical Impact: What This Buys You
Let me be concrete about what this architecture delivers in production.
Testing becomes trivial. Each derive layer is an independent function. You test the authentication derive by mocking cookies. You test the role derive by providing a mock user. You test the ownership derive by providing a mock resource. You never need to set up the full middleware chain to test one concern.
Debugging becomes linear. When a 403 fires, you know exactly which ring rejected the request. The error includes which derive failed. Was it authentication? Role? Ownership? State? You don't dig through a thousand-line function with a debugger. You check which ring said no.
New routes require zero auth code. When you add a new mentor-only endpoint, you add it to the mentor route group. The derive chain is already there. Authentication, role resolution, ownership verification — all inherited. You write the handler and nothing else.
Refactoring is safe. When we added a new role (ADMIN with sub-levels), we only touched the role derive. Not a single handler changed. The new role returned different context, and existing handlers that didn't care about admin sub-levels continued working untouched.
IX – Scoped Middleware: The Secret Weapon
Elysia has a concept called scoped middleware that makes this architecture possible. Without it, derive chains would be a nice idea that falls apart in practice.
Here's the problem: if every derive runs on every route, performance suffers. You don't need ownership verification on public routes. You don't need role resolution on health checks. You don't need state-dependent permissions on profile pages.
Scoped middleware means derives only run on the routes that need them.
In Elysia, you can define a derive at the plugin level, and it only applies to routes within that plugin. When you compose plugins into your app, each plugin brings its own derive chain.
The public routes plugin has one derive: authentication (and even that is optional — some public routes are truly anonymous). The mentor routes plugin has three derives: authentication, role resolution, and mentor-specific context. The session routes plugin has four: authentication, role, ownership, and state permissions.
Each route group carries exactly the authorization layers it needs. No more, no less.
This is computationally efficient. Public routes don't pay the cost of ownership lookups. But more importantly, it's semantically clear. When you look at a route group's plugin definition, you can see exactly what authorization layers protect it. The security model is visible in the code structure.
X – The Derive Chain Pattern in Practice
Let me walk you through a real request flow.
A mentee hits the endpoint to pause an active session. Here's what happens, in order:
Ring 1 — Authentication: The session cookie is read and validated. The derive returns the user's basic identity: ID 127, email confirmed, account active. If the cookie is missing or expired, the response is 401. The request stops.
Ring 2 — Role Resolution: The user's role is loaded. ID 127 is a MENTEE. The derive returns mentee-specific context: their credit balance, their active session count, their timezone. If the user had a role that couldn't access this route group, the response would be 403.
Ring 3 — Ownership Verification: The session ID from the URL is used to load the session. The derive checks whether user 127 is a participant. They are — they're the mentee. The derive returns the full session object, now scoped to this user's perspective (meaning: they see their own balance info, not the mentor's earnings). If they weren't a participant, 403.
Ring 4 — State Permissions: The session is currently ACTIVE. The derive computes: canPause is true (because the session is active and the user is a participant), canEnd is true, canCancel is false (too late — it's already started). These flags are added to context.
The handler: It reads canPause from context. It's true. It calls the session service to pause. Done.
The handler is three lines. All the authorization happened before it ran.
Want to see these patterns in action?
Oak Oliver's mentoring platform at mentoring.oakoliver.com runs entirely on this derive chain architecture. Every route — from mentor profile viewing to real-time session billing — is protected by composable authorization layers built with Elysia's derive() and scoped middleware.
If you're building APIs and want to discuss authorization architecture, session management, or Elysia.js patterns, you can book a 1-on-1 session at mentoring.oakoliver.com. Or explore the full portfolio of projects at oakoliver.com.
XI – Error Design: Why 403 Messages Matter
Most APIs return the same 403 for every authorization failure. "Forbidden." That's it. Good luck figuring out why.
With layered authorization, you can — and should — return specific error messages for each ring.
Ring 1 failure: "Authentication required. Please sign in."
Ring 2 failure: "Your account role does not have access to this resource." This tells the user it's a role issue, not a session issue.
Ring 3 failure: "You are not a participant in this session." Now they know it's an ownership problem.
Ring 4 failure: "This session cannot be paused because it has already ended." Now they know the state is the issue, not their identity or role.
Each ring produces a different error, and each error tells the user exactly what to fix.
This matters for API consumers — whether they're your own frontend or a third-party integration. Specific errors reduce support tickets. Generic errors generate confusion.
And in development, specific errors save hours of debugging. When the frontend gets "session cannot be paused because it has already ended," the developer knows the issue is state, not auth. They check the session status, not the auth flow.
XII – The Anti-Pattern: Checking Permissions in Handlers
Let me show you what we're avoiding, because the temptation is real.
The anti-pattern is putting permission checks inside route handlers. It looks harmless. The handler loads the resource, checks ownership, validates state, and then does the action.
The problem is that this pattern always drifts.
Developer A checks ownership carefully in their handler. Developer B copies the handler for a new route but forgets the ownership check. Developer C modifies the ownership check in one handler but not the other five that have identical logic.
Copy-pasted authorization logic is a vulnerability with a timer on it.
With derive chains, there's nothing to copy. The authorization is structural. It lives in the route group definition, not in individual handlers. A new developer adding a handler to the mentor route group gets mentor authorization automatically. They can't opt out of it without moving the route to a different group.
This is the difference between convention-based security ("remember to add the auth check") and architecture-based security ("auth check runs because of where the route lives").
Convention-based security fails at the speed of your team's growth. Architecture-based security scales regardless.
XIII – Performance: What Four Derives Cost You
A reasonable concern: doesn't running four derives per request add latency?
Let me break it down with real numbers from our production system.
Ring 1 (Authentication): Token validation is a cryptographic operation. With Bun's native crypto, this takes about 0.3ms. The session lookup hits an in-memory cache with a database fallback. Cached: 0.1ms. Uncached: 2-5ms.
Ring 2 (Role Resolution): The role is loaded alongside the user in Ring 1, so this is essentially free — it's extracting and typing data that's already in memory. About 0.05ms.
Ring 3 (Ownership): This requires a database query to load the resource and check the relationship. With a properly indexed foreign key, this is 1-3ms.
Ring 4 (State Permissions): Pure computation on the resource loaded in Ring 3. No database call. About 0.02ms.
Total: 1.5-8ms for the full chain. That's less than a single database query in most ORMs.
And remember — not every route runs all four rings. Public routes run zero. Authenticated-only routes run one. Most routes run two or three. The full four-ring chain only runs on the most sensitive, resource-specific endpoints.
The performance cost is negligible. The security benefit is massive.
XIV – Composability: Building New Route Groups
One of the most powerful aspects of this pattern is how easy it is to create new route groups with custom authorization.
When we added the admin dashboard, we didn't write new auth middleware. We composed existing derives in a new combination.
The admin route group uses Ring 1 (authentication), Ring 2 (role resolution — but scoped to ADMIN), and a new derive specific to admin actions that checks admin permission levels. No ownership ring — admins can access any resource. No state ring — admins can perform actions regardless of resource state.
Three weeks later, we added super-admin routes. These used the same admin derives plus an additional derive that checks whether the admin has elevated privileges. One new derive. Zero changes to existing code.
This is the composability promise of derive chains. Each layer is a building block. You combine them to create exactly the authorization surface you need. You never have to modify existing layers to add new ones.
XV – The Escape Hatch: When Derive Chains Don't Fit
I'd be dishonest if I said derive chains solve every authorization problem. They don't.
There are cases where the authorization logic is too dynamic for a static chain. Rate limiting that depends on the user's subscription tier. Feature flags that change based on A/B test groups. Complex multi-resource permissions where the decision depends on relationships between resources that aren't in the URL.
For these cases, we use what I call inline guards — lightweight permission checks inside the handler that use the context established by the derive chain.
The key difference from the anti-pattern: inline guards extend the derive chain's context, they don't replace it. The derive chain still handles authentication, role resolution, and ownership. The inline guard handles the edge case that couldn't be expressed as a derive.
Think of it as the castle analogy with a twist. The concentric walls handle the structural security. But sometimes the treasurer needs to check a specific document before opening a specific chest. That check happens inside the treasury, not at the castle wall.
The rule of thumb: if the check applies to every route in a group, it's a derive. If it applies to one or two routes, it's an inline guard that builds on the derive context.
XVI – Lessons from 100+ Mentoring Sessions
I've run over 100 mentoring sessions through mentoring.oakoliver.com, and authorization architecture comes up constantly. Here's what I've learned from discussing it with dozens of developers:
Most teams conflate authentication and authorization. They use "auth middleware" to mean both "verify identity" and "check permissions." These are different concerns with different lifecycles. Identity rarely changes during a session. Permissions change with every request.
Role-based access control (RBAC) is the starting point, not the destination. Roles tell you what kind of user someone is. But real-world permissions depend on resource ownership, resource state, billing status, and temporal conditions. Pure RBAC can't express "this mentor can pause this session because it's active and they're a participant and their account is in good standing."
The best authorization system is the one you can explain in one sentence. Ours: "Each derive layer adds context, and by the time the handler runs, it already knows what it's allowed to do." If your team can't explain your auth model in one sentence, it's too complex.
Authorization bugs are the scariest bugs. A rendering bug shows the wrong color. An authorization bug exposes someone else's data. The stakes are fundamentally different. This is why architecture-based security matters — it eliminates categories of bugs, not individual instances.
XVII – Why Elysia, Specifically
I've built API servers with Express, Fastify, Hono, and NestJS. Any of them could implement layered authorization as a pattern. But Elysia makes it native.
The type inference is the differentiator. When you derive new context, every downstream handler's type signature updates automatically. You don't maintain type annotations manually. You don't cast. You don't use generic type parameters. The types flow.
This matters because authorization is a trust boundary. When the types guarantee that a handler has access to the ownership-verified resource, you can trust that the ownership derive ran. If it hadn't, the code wouldn't compile.
The scoping model is the other differentiator. Elysia's plugin-based scoping means derive chains are defined at the structural level, not configured through decorators or middleware arrays. You see the authorization model in the code structure. It's not hidden in a configuration file or scattered across decorators.
For the mentoring platform — which has complex role hierarchies, per-resource ownership, and state-dependent permissions — Elysia's derive model was the right tool. Not because it's the only way to build layered authorization. But because it makes layered authorization the natural way to build.
XVIII – The Migration: From Monolith Middleware to Derive Chains
We didn't start with this architecture. We migrated to it.
The migration took about two weeks, and the process was surprisingly smooth because of one decision: we migrated from the outside in.
First, we extracted the authentication check into its own derive. Every route still ran the old monolith middleware, but now the authentication portion was delegated to a derive. We deployed this and verified nothing broke.
Next, we extracted role resolution. Same approach — the monolith middleware still ran, but the role check was now a derive that the monolith consumed. Deploy, verify.
Then ownership. Then state permissions.
At each step, the monolith middleware got smaller. By the time we extracted the fourth ring, the monolith was empty. We deleted it.
The key insight: you don't have to rewrite your auth system in one shot. Extract one concern at a time, wrap it in a derive, and let the old system delegate to the new one. When the old system has nothing left to delegate, remove it.
Incremental migration. Zero downtime. Zero auth regressions.
XIX – What I'd Do Differently
Hindsight is 20/20. If I were building this system again from scratch:
I'd start with derive chains from day one. The monolith middleware was a false economy. It was faster to write initially but cost us weeks of debugging and refactoring later. Starting with even two rings — authentication and role — would have saved the pain.
I'd invest more in error messages earlier. We initially returned generic 403s from all rings. Specific error messages came later, and every day without them was a day of slower debugging.
I'd build a derive chain visualizer. Something that renders the authorization flow for each route group as a diagram. We eventually built this as a test utility, but having it from the start would have helped onboard new developers faster.
I'd add audit logging at the derive level, not the handler level. We currently log authorization decisions in handlers. But the derive is where the decision actually happens. Logging at the derive level would give us a complete authorization audit trail without any handler code.
XX – The Deeper Question
Authorization architecture reveals how you think about trust.
If your auth is a single wall, you trust that wall to catch everything. When it fails, everything behind it is exposed.
If your auth is layered, each layer trusts the one before it — but also doesn't depend on it for everything. If the ownership ring fails, the role ring has already filtered out users who shouldn't be in this area at all. If the role ring fails, the authentication ring has already filtered out unauthenticated users.
Defense in depth isn't just a security buzzword. It's an architecture pattern.
The question I'll leave you with is this:
If you removed any single authorization check from your system, what would be exposed — and does that answer scare you?
If it does, you might need more rings.
– Antonio