Cross-cutting Concerns · Backend

Request-Scoped Context:
Why req.user Is an Anti-Pattern

Reading the authenticated user off the request object looks completely harmless the first time you write it. It's also the single easiest way to quietly wire your business logic to the fact that an HTTP request is currently in flight.

Authentication has a clear place in a layered architecture: the Interface layer, and only the Interface layer. The Domain and Application layers never depend on the authentication context — a Command or Query includes whatever it needs, like a plain userId string, and nothing about how that ID was established.

// forbidden — verifying the token directly in an Application Service
public async cancelOrder(token: string, command: CancelOrderCommand) {
  const user = await this.authService.verify(token)  // this is the Interface layer's job
  ...
}

The Subtler Mistake, Even Inside the Right Layer

Getting auth into the Interface layer isn't the whole story. Even there, reading the user info directly off the request object is a pattern worth avoiding, not just a style preference:

// avoid — reads the user info directly off the request object
public async cancelOrder(
  @Req() req: { user: { userId: string } },
  @Body() body: CancelOrderRequestBody
): Promise<void> {
  return this.commandService.cancelOrder({ ...body, userId: req.user.userId })
}

Reading straight off the request object couples the Handler — and anything it calls — to "there is an HTTP request happening right now," rather than to a plain value it was simply handed. That matters more than it looks for a field like the authenticated user, because that field is usually needed everywhere: inside the Handler, sometimes inside an Application-layer Service, and again inside a logging or observability interceptor. Threading req to every one of those call sites, or reaching for a framework-global "current request," both defeat the entire point of the Interface layer — converting HTTP mechanics into plain application calls in the first place.

The fix mirrors a pattern this repo already uses for Correlation IDs: store the value in request-scoped storage during the auth step, and read it back from that storage wherever it's needed, with no request object in sight.

// Interface layer: read the userId from request-scoped storage, not the request object
public async cancelOrder(
  @Body() body: CancelOrderRequestBody
): Promise<void> {
  const userId = userContextStorage.getRequesterId()
  return this.commandService.cancelOrder({ ...body, userId })
}

The Part That Actually Cost a Debugging Cycle

Implementing this for real turned up a subtlety the doc's one-paragraph description doesn't fully convey. The first attempt mirrored the existing Correlation ID pattern exactly: a Middleware opens an AsyncLocalStorage scope, an Auth Guard populates it. It passed type-checking, passed lint, passed all 117 unit tests. Then 58 of 67 end-to-end tests failed with 500 errors.

Two independent problems, each enough on its own: the e2e specs build their own testing module directly and never invoke the app's real bootstrap configuration, so the Middleware simply never ran in that test setup. And separately — this is the part that generalizes beyond any one test harness — a Guard's canActivate() returns a plain boolean. It has no callback representing "now continue processing the rest of the pipeline," which is exactly the shape AsyncLocalStorage.run(value, callback) requires. A Guard fundamentally cannot open that scope by itself, in any test setup, in production or otherwise.

The Fix: Split the Responsibility in Two

The Guard verifies the token and stashes the result as an internal-only handoff field on the request object — never read by a Controller, purely a relay to the next stage:

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly authService: AuthService,
    private readonly reflector: Reflector
  ) {}

  public async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()])
    if (isPublic) return true

    const request = context.switchToHttp().getRequest()
    const authorization = request.headers.authorization
    if (!authorization?.startsWith('Bearer ')) throw new UnauthorizedException()

    const token = authorization.replace('Bearer ', '')
    const user = await this.authService.verify(token)
    if (!user) throw new UnauthorizedException()

    // A Guard has no callback to wrap the rest of the pipeline, so it cannot itself open the
    // AsyncLocalStorage-based UserContextStore. This field is an internal-only handoff to
    // UserContextInterceptor — Controllers must never read it directly.
    request.__verifiedUser = user
    return true
  }
}

An Interceptor — which does get a wrappable next.handle() — is what actually opens the storage scope, around the rest of the request:

@Injectable()
export class UserContextInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest()
    const user = request.__verifiedUser

    if (!user) return next.handle()

    return new Observable((subscriber) => {
      UserContextStore.run(user, () => {
        next.handle().subscribe(subscriber)
      })
    })
  }
}

Both are always applied together, via one composite decorator, so a route can never end up with the Guard but not the Interceptor:

export const Authenticated = (): ReturnType<typeof applyDecorators> => applyDecorators(
  UseGuards(AuthGuard),
  UseInterceptors(UserContextInterceptor)
)

The storage itself is a thin wrapper, deliberately built to fail loudly rather than silently:

const storage = new AsyncLocalStorage<UserContext>()

export const UserContextStore = {
  run: (user: UserContext, fn: () => void) => storage.run(user, fn),
  getUser: (): UserContext | undefined => storage.getStore(),

  // Throws rather than returning undefined: a Controller method gated by @Authenticated()
  // should never reach this with no user set, so a thrown error surfaces a real wiring bug
  // immediately instead of silently propagating an empty requesterId into a Command/Query.
  getRequesterId: (): string => {
    const user = storage.getStore()
    if (!user) throw new Error('UserContextStore.getRequesterId() called outside an authenticated request context.')
    return user.userId
  }
}
Verifying it, not just testing it

Passing tests wasn't treated as proof of correctness for a mechanism this concurrency-sensitive. The real app was booted, two users were created, and both sequential and concurrent parallel requests confirmed that each response's data always matched that request's own bearer token — direct proof there's no cross-request leakage in the AsyncLocalStorage-based approach, which unit tests alone can't fully rule out.

Checking Other Languages Before Assuming They Need the Same Fix

A natural next question is whether four other language ports of this same architecture had the identical footgun. They didn't — and the reason why is the more useful takeaway. req.user = payload is a well-known, common pitfall specific to frameworks like Express and NestJS, which have no native concept of "the authenticated principal for this request." Frameworks that do have their own request-scoped auth abstraction were already correct without any change: Go's own context.Context plus context.WithValue, Spring Security's Authentication passed as an explicit method parameter (never touching HttpServletRequest directly), and FastAPI's Depends(get_current_user). Assuming every language needs the identical intervention would have meant dispatching four unnecessary fixes; checking each language's actual code first turned up that only one of the five needed to change at all.

Further reading in the repo

docs/architecture/authentication.md — the full auth flow and layer-placement principle · docs/architecture/cross-cutting-concerns.md — where auth sits in the request pipeline, and the Correlation ID pattern this mirrors · common/user-context-store.ts — the real code