Serverless State Bleed: Keep User Data Request-Scoped
Learn how reused serverless runtimes can expose stale user data, and how to isolate authentication and tenant state per request.
💡 Plain English Explainer (ELI5)
Treat authentication, tenant identity, and request-specific data like a visitor badge: it belongs to one visit and must not be left on a shared workbench. Some serverless runtimes reuse an execution environment or isolate for later requests. If code stores a user's context in module-level mutable state and a later request reads it, the application can cross its own user or tenant boundary.
Reuse is an implementation behavior, not a guarantee that a particular next user will reach the same runtime or that it will remain alive for a fixed period. A leak requires both shared execution state and a code path that carries request data forward or reads stale state. Keep per-request identity local to the request and pass it explicitly to the operations that need it.
Core Concepts & Key Terms
- Execution environment reuse
- A platform may keep a function runtime or isolate available and use it for later invocations; it may also retire it at any time.
- Module-level state
- A value initialized outside a handler. It may remain in that runtime between invocations, so it must not hold user- or tenant-specific data.
- AsyncLocalStorage
- Node.js API for propagating a store through one asynchronous execution context. It tracks context; it does not authenticate or authorize a user.
- Cross-request data exposure
- A failure where a later request reads data retained from an earlier request in a reused execution context. Reuse alone does not prove a leak.
Step-by-Step Attack Flow
One request creates request-specific data
Request A authenticates and resolves its own user or tenant context. A bug appears if code writes that identity into a mutable variable at module scope instead of keeping it in the handler scope.
The runtime may be reused
After the response, the platform may keep the execution environment warm. AWS documents environment reuse; Cloudflare Workers may reuse an isolate, but request routing and isolate lifetime are not a fixed user-to-user sequence.
A later request reaches the same runtime
If request B is handled by that same process or isolate, any module-level value left by request A remains available unless code overwrites or clears it. An early return or missing authentication check can make stale state reachable.
The application returns data under the wrong identity
If the handler uses that stale value for a database query or response, request B may receive data associated with request A. This is a conditional application-level isolation failure, not an inevitable property of serverless platforms.
Source Code: Flaw vs. Secure Implementation
// UNSAFE: request identity is mutable process-wide state.
import { NextResponse } from "next/server";
let cachedUserContext: { userId: string; role: string } | null = null;
export async function POST(req: Request) {
const token = req.headers.get("authorization");
if (token) {
cachedUserContext = await decodeUser(token);
}
// If token is missing, stale state can still be used.
return NextResponse.json({ actor: cachedUserContext?.userId });
}
// SAFE PATTERN: keep identity local and pass it to the service.
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const user = await authenticate(req);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const account = await loadAccountForUser(user.id);
return NextResponse.json({ actor: user.id, account });
}
// The service enforces ownership in its query:
async function loadAccountForUser(userId: string) {
return db.account.findFirst({ where: { ownerId: userId } });
}
Engineering Hardening Checklist
- Keep authentication, user identity, tenant IDs, and request-specific mutable data inside the handler or another explicitly request-scoped context; do not store them in module-level variables.
- Prefer explicit parameters when passing the authenticated principal to service and data-access functions. In Node.js, use
AsyncLocalStorage.run()only when implicit async context is needed; it is context propagation, not an authorization mechanism. - Do not treat a database client singleton as user state. Reuse clients when appropriate, but pass the authenticated tenant/user into each query and enforce ownership in the query predicate.
- Use
React cache()only for its documented React Server Components request-scoped memoization; do not use it as a general-purpose cross-request authentication cache. - Add an integration test that interleaves requests for distinct users and an unauthenticated request, then verifies that each response and database operation remains bound to its own principal.
- Review platform lifecycle documentation. Test the handler logic as though an execution environment may be reused, while avoiding assumptions about which request is scheduled on which instance.