The Login That Failed Open
A sign-in error I blamed on my hosting platform was a middleware ordering bug, and the emergency fallback I wrote around it quietly turned authentication off for three months.
In August 2025, my political mail catalog was protected by a single shared password. Staff typed it once, the app stored a flag in their session, and every later request checked that flag. On Render, where the app is hosted, signing in failed with an error from Starlette, the framework under FastAPI:
AssertionError: SessionMiddleware must be installed to access request.session
SessionMiddleware was installed. It was the first middleware in the file, under a comment saying it had to be. So I decided the problem was Render.
It wasn’t. The bug was the order my middleware ran in, and the fallback I wrote while blaming the platform made it far worse: for about three months, it let requests through without checking the password.
Middleware is an onion
A web framework’s middleware is a set of layers wrapped around the app. A request passes through each layer on the way in, reaches the route, and the response passes back out through the same layers in reverse. A session layer reads the cookie and attaches request.session on the way in. An authentication layer reads request.session to decide whether to let the request through. So the session layer has to sit outside authentication: the request must reach it first.
The question is which layer is outermost, and in Starlette the answer is the opposite of the order you write them. Each call to add_middleware inserts the new layer at the front of the list:
def add_middleware(self, middleware_class: type, **options: typing.Any) -> None:
...
self.user_middleware.insert(0, Middleware(middleware_class, **options))
The front of the list becomes the outermost layer. The last middleware added is the first one a request meets. FastAPI’s @app.middleware("http") decorator is the same call underneath.
Here is how my file read:
# Add SessionMiddleware first - this is critical for session access
app.add_middleware(
SessionMiddleware,
secret_key=session_secret,
max_age=session_timeout_seconds,
same_site="lax",
https_only=not settings.debug,
)
# Add CORS middleware early in the stack (after SessionMiddleware)
app.add_middleware(CORSMiddleware, allow_origins=["*"], ...)
app.add_middleware(SlowAPIMiddleware)
@app.middleware("http")
async def authentication_middleware(request: Request, call_next):
... # reads request.session
The comment is the bug in one line. “First” in the file meant last in the request:
flowchart TB
subgraph run ["Order a request runs through"]
direction TB
r1["Authentication<br/>reads request.session: not there yet"] --> r2["Rate limit"] --> r3["CORS"] --> r4["Session<br/>attaches request.session"] --> r5["Route"]
end
subgraph written ["Order in main.py (simplified)"]
direction TB
w1["1. Session"] --> w2["2. CORS"] --> w3["3. Rate limit"] --> w4["4. Authentication"]
end
classDef bad stroke:#8fc1f0,stroke-width:2px
class r1 bad
Authentication ran before the session existed, asked for it, and Starlette’s assertion fired.
Blaming the platform
That isn’t what I concluded at the time. The error said SessionMiddleware wasn’t installed, I could see that it was, and the app was failing on a host I didn’t control, so I went looking for what Render was doing differently.
Over the next day I made seven commits. I tried three different session configurations “for Render compatibility”. I built a small test app to check whether the session middleware really worked. I wrote five documents explaining the fix, three of them titled FINAL, COMPLETE, and SIMPLIFIED. Then I replaced Starlette’s session middleware entirely, with about 670 lines of my own Redis-backed session layer.
None of it could work, because none of it changed the order. The new session middleware was added the same way as the old one: first in the file, so last in the request.
The fallback that turned off the lock
The worst change came on the first evening. To keep the app usable while I searched, I made the authentication check catch the session error and let the request through:
try:
# Test basic session access - this will raise AssertionError if SessionMiddleware failed
_ = dict(request.session)
except (AssertionError, AttributeError) as session_error:
logger.error(
f"CRITICAL SESSION ISSUE: {session_error}. "
"SessionMiddleware failed to install properly on Render."
)
# EMERGENCY FALLBACK: Allow access but log the security issue
logger.warning(
f"EMERGENCY FALLBACK: Allowing access to {request.url.path} due to session failure"
)
response = await call_next(request)
response.headers["X-Security-Warning"] = (
"Session middleware failed - authentication bypassed"
)
return response
There were three of these: one for a session layer that failed to start, one for a session that couldn’t be read, and one for any other error in the check. Each added a warning header to the response that no browser shows anyone.
Read against the middleware order, the effect is plain. The session check failed on every protected request, because authentication always ran before the session existed, and so the fallback let every protected request through. The error stopped looking like a broken login because the login had stopped being checked. The log line even blamed Render, so the one place the truth was written down pointed at the wrong suspect.
Failing closed
On 13 November I realized that pages like the upload form would open without signing in. That afternoon I made eight commits in under two hours.
The first removed every fallback. Authentication would now fail closed: if it couldn’t confirm a session, it refused the request, except on a short list of paths that have to work signed out, like the sign-in page itself, the health check, and static files.
The bug surfaced within minutes. The browser stopped with too many redirects, because every request now failed the session check and was sent to the sign-in page, and the sign-in page touched the session too. The fallback had been hiding the error; failing closed made it loud.
I chased the redirect loop for an hour: logging, headers to stop browsers caching redirects, and moving authentication into a proper middleware class. That last change put the new class “after” the session middleware in the file, which I expected would make it run after the session loaded. It was the same mistake a second time. Twenty minutes later I found the rule, and the final version spells it out in the comments:
# CRITICAL: Middleware added via app.add_middleware() executes in REVERSE order
# Last added = First executed. So we add in reverse order of desired execution:
# Add rate limiting middleware FIRST (will execute LAST)
app.add_middleware(SlowAPIMiddleware)
# Add CORS middleware SECOND (will execute SECOND-TO-LAST)
app.add_middleware(CORSMiddleware, ...)
# Add authentication middleware THIRD (will execute SECOND, after session loads)
app.add_middleware(
AuthenticationMiddleware,
redis_session_middleware_installed=redis_session_middleware_installed,
)
# Add session middleware LAST (will execute FIRST)
# This is CRITICAL - middleware added last executes first!
app.add_middleware(RedisSessionMiddleware, **session_config)
Sign-in worked, and nothing behind it opened without a session.
The cost of failing closed
Failing closed has a price: the app’s availability now depends on everything the check depends on. The session store lives in Redis, and its health was checked once, at startup. If Redis was unreachable at that moment, the app marked sessions as unavailable for good, and every page behind sign-in answered “Session management unavailable” until someone restarted it. In July 2026 I replaced the startup check with one that polls Redis every 20 seconds, so the app recovers on its own once Redis comes back.
That trade is the right one. An outage that refuses everyone is visible, and someone fixes it. A fallback that admits everyone looks exactly like a working app.
The core issue
Two things went wrong, and the second hid the first.
The first was a mental model: I assumed middleware runs in the order it is written. Starlette runs it in the reverse, and nothing in the code says so unless you write the comment yourself. A wrong comment is worse than none, because it turns an assumption into something that looks like a fact.
The second was the error handling. A security check that fails open converts a crash into a silent bypass. Every symptom that would have led me to the bug, the error page, the failed sign-in, the stack trace, was replaced by a request that succeeded. If a check exists to say no, then when it can’t decide, the answer has to be no.