~/webline_global $

// Everyday tech, explained simply.

Why Your Fastify Middleware Fails on Concurrent Auth Checks

· 10 min read
Why Your Fastify Middleware Fails on Concurrent Auth Checks

We’ve all been there. You spin up a Fastify server, layer in a preHandler for auth checks, and everything hums along in development. Then you deploy, hit it with concurrent requests under load, and suddenly users are getting 401s for valid tokens, or worse, the server starts throwing race-condition errors that make no sense at all. The culprit isn’t your auth logic per se—it’s how Fastify’s request lifecycle handles shared state when multiple requests hit the same middleware simultaneously.

This isn’t a bug in Fastify. It’s a design pattern mismatch that catches even experienced Node.js developers off guard. The framework’s hook system is incredibly performant because it runs middleware in a serialized, non-blocking fashion, but it doesn’t protect you from mutating objects that live outside the request context. Once you understand where that mutation happens, you can fix it for good.

The Anatomy of a Concurrent Auth Failure

To understand why your middleware breaks under concurrency, you need to see how Fastify processes requests. Each incoming request spawns a new request object and a reply object. Your preHandler hooks run sequentially per request, but multiple requests can be in-flight at the same time, each in a different phase of the lifecycle.

The problem emerges when your middleware writes to a shared variable—a module-level cache, a mutable object on the request itself, or a closure variable that isn’t properly scoped. Under a single request, it’s fine. Under ten concurrent requests, one handler can overwrite the state another handler just read.

The Shared State Trap

Here’s a pattern I’ve seen in production code more times than I can count. A developer writes an auth middleware that fetches a user’s session from Redis, then stores it on the request object for downstream handlers.

// Dangerous pattern
let currentUser = null;

fastify.addHook('preHandler', async (request, reply) => {
  const token = request.headers.authorization;
  currentUser = await sessionService.validate(token);
  request.user = currentUser;
});

Looks innocent, right? Under a single request, currentUser is set and used before the next handler runs. But Node.js is single-threaded, and async/await yields control of the event loop. If two requests arrive at nearly the same time, the first request’s await pauses the handler, the second request overwrites currentUser, and when the first request resumes, it reads the wrong user.

The fix is simple: never use a module-scoped variable to hold per-request state. But this is just the tip of the iceberg. The deeper issue involves closures and how Fastify’s hook system binds its context.

How Fastify’s Lifecycle Amplifies the Problem

Fastify runs hooks in a specific order: onRequest, preParsing, preValidation, preHandler, and then the route handler itself. Each hook can be async, and Fastify does not wait for all hooks across all requests to complete before starting the next request. That’s the whole point of its performance advantage.

But here’s the nuance: if your middleware modifies a property on the request object that is also read by another hook running concurrently on a different request—and that property is shared via a reference—you’ve created a race condition. The request object itself is unique per request, so that’s safe. The danger is when your middleware reaches outside the request scope.

Consider a caching layer. You might decide to cache the result of a token validation to avoid hitting Redis on every request.

const tokenCache = new Map();

fastify.addHook('preHandler', async (request, reply) => {
  const token = request.headers.authorization;
  if (tokenCache.has(token)) {
    request.user = tokenCache.get(token);
    return;
  }
  const user = await sessionService.validate(token);
  tokenCache.set(token, user);
  request.user = user;
});

This looks efficient, but it’s not thread-safe. If two requests with different tokens arrive at the same time, and both miss the cache, both will call sessionService.validate. That’s wasteful but not catastrophic. The real danger comes if your cache eviction logic or validation function has side effects that mutate global state.

The Real Culprit: Mutable Default Arguments and Closure Bindings

JavaScript’s default parameter evaluation happens at call time, not at definition time. If you define a middleware function with a mutable default argument, you’re setting yourself up for a concurrent disaster. This is a classic gotcha that’s exacerbated by Fastify’s concurrent request handling.

Default Parameters That Bite You

Take a look at this pattern:

function authMiddleware(options = { cache: new Map() }) {
  return async (request, reply) => {
    const token = request.headers.authorization;
    if (options.cache.has(token)) {
      request.user = options.cache.get(token);
      return;
    }
    // ...
  };
}

fastify.addHook('preHandler', authMiddleware());

Every time authMiddleware() is called without arguments, a new Map is created. But if you call authMiddleware() once and reuse the returned function across multiple route registrations, you get a single shared Map instance. That’s fine for a single-request world, but under concurrency, two requests can simultaneously read and write to that map, leading to inconsistent state.

The fix is to either instantiate the cache per request or use a proper concurrent-safe cache like lru-cache with appropriate max size and ttl settings.

Closure Variables and the Event Loop

Closures capture variables by reference. If your middleware function closes over a variable that gets reassigned outside the hook, you’re in trouble. Here’s a real-world example from a payment integration I debugged last year.

A team had built a rate limiter that tracked request counts in a global object. The middleware closed over that object, and under load, the counts became negative because two requests decremented the same counter before either one finished.

const rateLimitState = { tokens: 10 };

fastify.addHook('preHandler', async (request, reply) => {
  if (rateLimitState.tokens <= 0) {
    reply.code(429).send('Too Many Requests');
    return;
  }
  rateLimitState.tokens--;
  // ... do work
  rateLimitState.tokens++;
});

The decrement and increment are not atomic. Under concurrency, you can have two requests read tokens as 1, both decrement to 0, and then both increment back to 1, effectively losing a rate limit violation. The solution is to use an atomic operation or a dedicated rate-limiting library like rate-limiter-flexible.

Diagnosing the Failure Under Load

You can’t always reproduce concurrent auth failures in a local environment with a single user. You need to simulate real traffic patterns. The first sign is usually intermittent 401 errors or server logs showing that the request.user object is undefined or has the wrong value.

Reproducing the Race

The easiest way to trigger the bug is with a simple load test using autocannon or wrk. Fire 50 concurrent requests with valid tokens at your Fastify server and watch the error rate. If you see even a single 401, you’ve got a race condition.

I once spent an entire afternoon tracking down a bug where the auth middleware would occasionally return a user object from a previous request’s session. The team had stored the session data in a global WeakMap keyed by the request object itself, thinking it would be garbage-collected. The problem was that the WeakMap entry was set in one hook and read in another, and under concurrency, the garbage collector didn’t run between the two operations, so the old value persisted.

Logging Without Side Effects

When debugging concurrent issues, be careful with your logging. If your logger writes to a shared stream synchronously, it can mask the problem or introduce its own race conditions. Use Fastify’s built-in request-logging capabilities, which are already scoped per request.

Add a unique request ID to every log line. Fastify provides request.id out of the box. If you see two different request IDs associated with the same user object in your logs, you’ve confirmed the race.

Architecting Concurrent-Safe Auth Middleware

Once you know what to look for, building concurrent-safe middleware is straightforward. The key principle is: do not share mutable state between requests. Every piece of data that belongs to a single request must be stored on that request’s object or in a data store that handles concurrency natively.

Pattern 1: Scoped Context with AsyncLocalStorage

Node.js 14+ includes AsyncLocalStorage, which gives you a way to store context that follows the async execution chain. Fastify doesn’t use it by default, but you can integrate it manually.

const { AsyncLocalStorage } = require('async_hooks');
const authStorage = new AsyncLocalStorage();

fastify.addHook('onRequest', (request, reply, done) => {
  authStorage.run(new Map(), done);
});

fastify.addHook('preHandler', async (request, reply) => {
  const store = authStorage.getStore();
  const token = request.headers.authorization;
  if (store.has(token)) {
    request.user = store.get(token);
    return;
  }
  const user = await sessionService.validate(token);
  store.set(token, user);
  request.user = user;
});

This ensures that even if the validation function yields the event loop, the store remains scoped to the originating request. It’s not a silver bullet—you still need to avoid global state—but it prevents the most common class of race conditions.

Pattern 2: Stateless Validation with JWTs

The simplest way to eliminate concurrent auth failures is to make your middleware stateless. If you use JSON Web Tokens (JWTs) with a public key, you can validate the token entirely in-process without any shared state.

const jwt = require('jsonwebtoken');
const publicKey = fs.readFileSync('./public.pem');

fastify.addHook('preHandler', async (request, reply) => {
  try {
    const token = request.headers.authorization.replace('Bearer ', '');
    request.user = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
  } catch (err) {
    reply.code(401).send({ error: 'Unauthorized' });
  }
});

No cache, no shared variables, no race conditions. Every request validates its token independently. The trade-off is that you can’t revoke individual tokens without a blocklist, but for many applications, that’s acceptable.

Pattern 3: Concurrent-Safe Caching with Atomic Operations

If you must cache validation results (for performance), use a cache that supports atomic check-and-set operations. Redis with SETNX or a proper TTL-based cache library like lru-cache with max and ttl options will prevent the double-validation problem.

const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });

fastify.addHook('preHandler', async (request, reply) => {
  const token = request.headers.authorization;
  let user = cache.get(token);
  if (user === undefined) {
    user = await sessionService.validate(token);
    cache.set(token, user);
  }
  request.user = user;
});

The lru-cache library is internally synchronized for Node.js’s single-threaded model, meaning two concurrent requests won’t corrupt the cache structure. They might both call validate if they arrive before the first one finishes setting the cache, but that’s a minor inefficiency, not a correctness bug.

When Fastify’s Encapsulation Works For You

Fastify has a powerful feature called encapsulation that most developers underutilize. You can register a plugin with its own context, and that context’s hooks and decorators are isolated from the parent application. This is a natural boundary for per-tenant or per-route auth middleware.

Using Plugins to Isolate State

If you have multiple authentication strategies (e.g., user auth and admin auth), register each as a separate plugin. The hooks in one plugin won’t interfere with the hooks in another, even under concurrent load.

async function userAuthPlugin(fastify, opts) {
  fastify.decorateRequest('user', null);
  fastify.addHook('preHandler', async (request, reply) => {
    // user-specific auth logic
  });
}

async function adminAuthPlugin(fastify, opts) {
  fastify.decorateRequest('admin', null);
  fastify.addHook('preHandler', async (request, reply) => {
    // admin-specific auth logic
  });
}

fastify.register(userAuthPlugin);
fastify.register(adminAuthPlugin);

Encapsulation ensures that the user decorator in the first plugin is a different property than anything in the second plugin. This prevents accidental overwrites when both plugins run on the same request.

A Concrete Anecdote: The Casino Auth Meltdown

I was consulting for a small iGaming studio that built their backend on Fastify. Their auth middleware looked clean: it fetched the player’s session from Redis, validated the token, and attached a player object to the request. Under 200 concurrent users during a promotional event, the system started logging players into each other’s accounts.

The root cause was a global variable in the middleware module that held the last validated player’s ID. The developer had used it for debugging and never removed it. When request A yielded on the Redis call, request B set the global variable to its own player ID. When request A continued, it read the global variable to build the player object. Players saw each other’s balances.

The fix took five minutes: delete the global variable and use request.player directly. But the damage to trust and the cost of the incident response was significant. That’s the price of a single mutable reference.

Looking Ahead: Zero-Trust Middleware Patterns

The industry is moving toward a zero-trust model for middleware, where every request is treated as an independent transaction with no shared context. Fastify’s design supports this well if you follow a few rules.

First, never use module-level variables for per-request state. Second, treat your middleware as pure functions that take a request and reply and return nothing—no side effects beyond those two objects. Third, if you must cache, use a library that is explicitly designed for concurrent access.

The next frontier is using WebAssembly modules for auth validation, running in a sandboxed environment that has no access to the host’s memory space. That would eliminate race conditions at the architectural level. But for now, the discipline of scoping your state correctly is the single most effective thing you can do.

When you ship your next Fastify API, run a load test before you deploy. Fire 100 concurrent requests at your auth endpoint and watch for any 401s that shouldn’t exist. If you see one, you know exactly where to look: the shared state hiding in plain sight.