Middleware Execution OrderingΒΆ
ContextForge uses Starlette's ASGI middleware stack to handle cross-cutting concerns like authentication, CSRF protection, logging, and observability. Understanding the execution order is critical when adding new middleware, especially when middleware depends on request state populated by other middleware.
Reverse Registration RuleΒΆ
Starlette/FastAPI executes middleware in reverse registration order. The last app.add_middleware() call registers middleware that runs outermost (processes requests first, responses last). The first add_middleware() call registers middleware that runs innermost (processes requests last, responses first).
Worked ExampleΒΆ
Suppose your code registers middleware like this:
app.add_middleware(First) # Registered first
app.add_middleware(Second) # Registered second
app.add_middleware(Third) # Registered third (last)
The execution order on an inbound request is:
Request β Third β Second β First β Handler β First β Second β Third β Response
(runs) (runs) (runs) (done) (done) (done)
Third runs first on the request path (outermost), and First runs last (innermost, closest to the handler). On the response path, the order reverses.
This reverse-registration rule is a Starlette/FastAPI design decision. When a middleware reads request.state values set by another middleware, you must register the producer middleware after the consumer middleware so that the producer runs before (earlier on the request path than) the consumer.
Current Middleware StackΒΆ
The middleware stack in mcpgateway/main.py (lines ~3265β3501) includes ~26 middleware, plus one outlier registered after route setup. The table below shows them in registration order (top = registered first, bottom = registered last). Per the Reverse Registration Rule above, execution order on the request path is the reverse of this table β deriving an exact per-row execution rank by hand is error-prone (several rows are conditionally registered, which shifts everyone after them), so it is intentionally omitted here; see the prose sections below the table (state dependencies, the outlier) for the orderings that actually matter in practice, worked out and verified against main.py directly:
| Registration Order | Middleware | Purpose | Conditional |
|---|---|---|---|
| 1 | CORSMiddleware | Handles CORS preflight and cross-origin policy | Always |
| 2 | SSEAwareCompressMiddleware | Compresses responses (Brotli/Zstd/GZip), excludes SSE/MCP streams | compression_enabled |
| 3 | SecurityHeadersMiddleware | Adds CSP, X-Frame-Options, HSTS, strips Server headers | Always |
| 4 | HeaderSizeMiddleware | Validates request header count/size (RFC 6585 Β§5) | header_size_validation_enabled |
| 5 | RateLimitMiddleware | Rate-limiting enforcement per IP/user tier | rate_limiting_enabled |
| 6 | ValidationMiddleware | Input validation and output sanitization | validation_middleware_enabled |
| 7 | MCPProtocolVersionMiddleware | Validates MCP-Protocol-Version header on MCP routes | Always |
| 8 | BaseHTTPMiddleware (token scoping) | Applies token scoping for email-auth users (data filtering) | email_auth_enabled |
| 9 | MCPPathRewriteMiddleware (token scoped) | Streamable HTTP for MCP routes with token scoping | email_auth_enabled |
| 10 | MCPPathRewriteMiddleware | Streamable HTTP for MCP routes (no scoping) | NOT email_auth_enabled |
| 11 | HttpAuthMiddleware | Plugin hook for HTTP auth (e.g., OAuth/OIDC) | Always |
| 12 | RequestLoggingMiddleware | Gateway boundary and detailed request/response logging | Always |
| 13 | DocsAuthMiddleware | Restricts /docs and /redoc to authenticated users | Always |
| 14 | AdminAuthMiddleware | Requires admin privilege for /admin/* routes | Always |
| 15 | ForwardedHostMiddleware | Rewrites Host from X-Forwarded-Host (proxy support) | Always |
| 16 | ProxyHeadersMiddleware | Trusts X-Forwarded-* headers (scheme, IP) | Always |
| 17 | CorrelationIDMiddleware | Attaches request correlation ID for tracing | correlation_id_enabled |
| 18 | CSRFMiddleware | CSRF token validation (state-changing requests) | csrf_enabled |
| 19 | PasswordChangeEnforcementMiddleware | Redirects users requiring password change (admin-only) | password_change_enforcement_enabled |
| 20 | AuthContextMiddleware | Extracts and validates JWT/session, sets request.state.user | security_logging_enabled OR siem_export_enabled with "auth" events OR mcpgateway_admin_api_enabled OR password_change_enforcement_enabled |
| 21 | TokenUsageMiddleware | Logs token usage for analytics/audit | token_usage_logging_enabled |
| 22 | ObservabilityMiddleware | Traces request spans for observability | observability_enabled |
| 23 | OpenTelemetryRequestMiddleware | OTEL request root span | otel_tracing_enabled() |
| 24 | BaggageMiddleware | OTEL baggage extraction from headers | otel_baggage_enabled AND otel_tracing_enabled() |
| 25 | DBQueryLoggingMiddleware | Logs database queries for N+1 detection | db_query_log_enabled |
| 26 | ClientDisconnectMiddleware | Cancels in-flight handlers on client disconnect | client_disconnect_middleware_enabled |
| 27 (outlier) | DeprecationHeadersMiddleware | Adds RFC 8594 Sunset/Link headers to legacy routes | legacy_api_enabled |
Row 27 is the one row whose execution position is unambiguous and worth calling out here: it is registered after everything else (see The Outlier below), which per the reverse-registration rule makes it the outermost middleware in the stack β it runs first on the request path, ahead of even CORSMiddleware (row 1).
Rows 18β20 (CSRFMiddleware, PasswordChangeEnforcementMiddleware, AuthContextMiddleware) are registered in that order, so among just those three, AuthContextMiddleware β registered last of the three β is the outermost of the three and runs first among them on the request path, populating request.state.user before CSRFMiddleware or PasswordChangeEnforcementMiddleware execute. That is the intended, correct behavior; see State Dependencies below. Note that AuthContextMiddleware is not registered last in the main block overall β TokenUsageMiddleware, ObservabilityMiddleware, OpenTelemetryRequestMiddleware, BaggageMiddleware, DBQueryLoggingMiddleware, and ClientDisconnectMiddleware (rows 21β26) are all registered after it, so it is not the innermost middleware overall either.
The Outlier: DeprecationHeadersMiddlewareΒΆ
Key point: DeprecationHeadersMiddleware is registered outside the main middleware block, after app.include_router(legacy_router) (line ~12882). Because it is registered last, it runs outermost β ahead of everything in the main 3265β3501 registration block, including CORSMiddleware.
If you are adding middleware that depends on early request processing (e.g., extracting headers or setting flags), DeprecationHeadersMiddleware may already have run if legacy_api_enabled=true. If you add middleware after DeprecationHeadersMiddleware, it will run before DeprecationHeadersMiddleware, which is usually not desired.
State Dependencies: The CSRF + Auth Middleware Ordering BugΒΆ
Critical constraint: CSRFMiddleware must be registered before AuthContextMiddleware so that CSRF runs after auth and request.state.user is already populated.
The Bug (Issue #5780)ΒΆ
CSRFMiddleware validates HMAC-bound CSRF tokens using request.state.user.email:
# mcpgateway/middleware/csrf_middleware.py:143β146
if hasattr(request.state, "user") and request.state.user:
user = request.state.user
user_id = user.email if hasattr(user, "email") else str(user.id)
AuthContextMiddleware populates request.state.user for all authenticated requests:
If the middleware registration order is reversed (CSRF registered after auth), then CSRF runs before auth on the request path, and request.state.user is unset. The CSRF middleware then silently falls back to extracting identity from the raw JWT sub claim (the user's ID, not email). But admin.py binds CSRF tokens to the user's email, not ID β resulting in universal 403 "CSRF Token Invalid" errors on all Admin UI writes.
Solution: In main.py, register CSRFMiddleware (line ~3403) before AuthContextMiddleware (line ~3427) so that the reverse-order execution makes auth run first on requests.
Same-Shape Constraint: PasswordChangeEnforcementMiddlewareΒΆ
PasswordChangeEnforcementMiddleware has an identical dependency structure to CSRFMiddleware: it reads request.state.user to check the password_change_required flag (line ~96 in password_change_enforcement.py):
user: Optional[EmailUser] = getattr(request.state, "user", None)
if not user:
return await call_next(request)
For the same reason, PasswordChangeEnforcementMiddleware must be registered before AuthContextMiddleware. In the current codebase, both CSRF and PasswordChangeEnforcement are registered in the same order-dependent block (lines ~3403β3427), with both registered before auth.
If you add another middleware that reads request.state values set by AuthContextMiddleware, ensure it is registered before AuthContextMiddleware to preserve execution order.
Guidance for Adding New MiddlewareΒΆ
-
Identify producers and consumers. List the
request.stateattributes your middleware reads and writes. Identify which other middleware or dependencies populate those attributes. -
Register consumers before producers. Middleware that reads
request.state.user,request.state.jti, or other auth context must be registered before (earlier in the registration list than)AuthContextMiddleware. -
Add a regression test. Write a test similar to
test_csrf_middleware_runs_after_auth_context_middleware(intests/unit/mcpgateway/middleware/test_admin_csrf_binding.py:325) to verify your middleware runs in the expected order:
def test_my_middleware_runs_after_auth_context_middleware():
from mcpgateway.main import app
from mcpgateway.middleware.auth_middleware import AuthContextMiddleware
from mcpgateway.middleware.my_new_middleware import MyNewMiddleware
middleware_classes = [m.cls for m in app.user_middleware]
assert AuthContextMiddleware in middleware_classes
assert MyNewMiddleware in middleware_classes
auth_index = middleware_classes.index(AuthContextMiddleware)
my_index = middleware_classes.index(MyNewMiddleware)
# AuthContextMiddleware must run BEFORE MyNewMiddleware
# (lower index means earlier in request processing)
assert auth_index < my_index
-
Consider the reverse-registration rule.
add_middleware()calls at the end register middleware that runs first on requests. When adding new middleware, reference this page (docs/docs/architecture/middleware-ordering.md) in the code comment next to yourapp.add_middleware()call to document your ordering rationale. -
Avoid brittle line-number references. When documenting middleware order in code or docs, reference the middleware class name and its high-level purpose (e.g., "registered before AuthContextMiddleware") rather than specific line numbers, since the codebase evolves.
See also: - Security Features β overview of ContextForge's security model - Configuration: CSRF Protection β CSRF-specific settings and cookie synchronization risks