# Effect `AsyncLocalStorage` context lost/contaminated inside Effect fibers under concurrent load with RPC

Effect's MixedScheduler drains batched fiber continuations in one ALS context, so concurrent RPC requests read another request's Clerk session, enabling auth bypass.

- Payout: Research
- Severity: high
- Program: Effect
- Found by: jamesone
- Disclosed: 2026-03-20
- CVE: CVE-2026-32887
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-38f7-945m-qr2g
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-38f7-945m-qr2g
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:nextjs tech:nodejs technique:race-window vuln:access-control vuln:auth-bypass vuln:race-condition

**Target:** npm effect runtime / @effect/rpc web handler on Node.js
**Root cause:** MixedScheduler batches fiber continuations and drains them all inside a single microtask/timer callback, so every resumed fiber inherits the AsyncLocalStorage context of whichever request first triggered the drain. toWebHandlerRuntime sets Effect's own Context per request but never captures or restores the Node ALS context at fork time.
**Steps:**
1. Run two concurrent requests through RpcServer.toWebHandler, each wrapped in requestStore.run({userId: 'user-A'|'user-B'}, ...).
2. Request A's fiber yields at an Effect.tryPromise boundary, schedules its continuation, and calls starve() since running=false, capturing A's ALS context in the Promise.then callback.
3. Request B's fiber yields while running=true, so its continuation is queued without a new starve().
4. starveInternal drains both continuations in the same callback; B's `async () => auth()` thunk reads A's ALS store.
5. Observe both responses returning alsUserId 'user-A'.
**How found:** Reporter noticed auth() inside an Effect RPC handler returned the wrong user under production concurrency but worked locally; traced it to MixedScheduler.starveInternal draining all queued fiber continuations in one callback and toWebHandlerRuntime forking fibers without ALS propagation, then built a minimal repro with a simulated requestStore.
**Exploitation:** Under concurrent load on a Next.js App Router deployment using @clerk/nextjs, an RPC handler calling auth() inside Effect.tryPromise can receive another concurrent user's session, letting an attacker act as the victim user whose request happened to seed the scheduler drain.
**Escalation:** Any ALS-dependent API (Clerk auth(), Next.js cookies()/headers(), OpenTelemetry trace context) inside Effect fibers is contaminated, turning a scheduling bug into cross-user authentication bypass and data leakage.
**Insight:** When a runtime batches continuations into a shared microtask/timer callback, any ambient async context (ALS, thread-locals) is captured once per drain, not per task — audit fiber/actor runtimes that fork work from a request-scoped framework for missing AsyncLocalStorage.snapshot() capture at schedule time.

## Proof of concept (copied exactly from the original report)

**Minimal reproduction with two concurrent ALS contexts**

```
import { AsyncLocalStorage } from "node:async_hooks"
import { Effect, Layer } from "effect"
import { RpcServer, RpcSerialization, Rpc, RpcGroup } from "@effect/rpc"
import { HttpServer } from "@effect/platform"
import * as S from "effect/Schema"

// Simulate a framework's ALS (like Next.js / Clerk)
const requestStore = new AsyncLocalStorage<{ userId: string }>()

class GetUser extends Rpc.make("GetUser", {
  success: S.Struct({ userId: S.String, alsUserId: S.String }),
  failure: S.Never,
  payload: {}
}) {}

const MyRpc = RpcGroup.make("MyRpc").add(GetUser)

const MyRpcLive = MyRpc.toLayer(
  RpcGroup.toHandlers(MyRpc, {
    GetUser: () =>
      Effect.gen(function*() {
        // Simulate calling an ALS-dependent API inside an Effect fiber
        const alsResult = yield* Effect.tryPromise({
          try: async () => {
            const store = requestStore.getStore()
            return store?.userId ?? "NONE"
          },
          catch: () => { throw new Error("impossible") }
        })
        return { userId: "from-effect-context", alsUserId: alsResult }
      })
  })
)

const RpcLayer = MyRpcLive.pipe(
  Layer.provideMerge(RpcSerialization.layerJson),
  Layer.provideMerge(HttpServer.layerContext)
)

const { handler } = RpcServer.toWebHandler(MyRpc, { layer: RpcLayer })

// Simulate two concurrent requests with different ALS contexts
async function main() {
  const results = await Promise.all([
    requestStore.run({ userId: "user-A" }, () => handler(makeRpcRequest("GetUser"))),
    requestStore.run({ userId: "user-B" }, () => handler(makeRpcRequest("GetUser"))),
  ])

  // Parse responses and check if alsUserId matches the expected user
  // Under the bug: both responses may show "user-A" (or one shows the other's)
  for (const res of results) {
    console.log(await res.json())
  }
}
```

**RPC handler calling auth() inside Effect fiber**

```
export const getAuthenticatedUserId: Effect.Effect<string, UnauthorizedError> =
  Effect.gen(function*() {
    const authResult = yield* Effect.tryPromise({
      try: async () => auth(),
      catch: () =>
        new UnauthorizedError({
          message: "Failed to get auth session"
        })
    })

    if (!authResult.userId) {
      return yield* Effect.fail(
        new UnauthorizedError({
          message: "Authentication required"
        })
      )
    }

    return authResult.userId
  })
```

**Vulnerable toWebHandler POST route replica**

```
const { handler: webHandler, dispose } = RpcServer.toWebHandler(DemoRpc, {
  layer: RpcLayer,
  memoMap: sharedMemoMap,
});

/**
 * POST /api/rpc/demo
 */
export const POST = async (request: Request) => {
  return webHandler(request);
};
```

