How Caching DataLoader Instances Leaked Salary Bands Across 58 Orgs for 11 Days
← Back
September 3, 2026Security10 min read

How Caching DataLoader Instances Leaked Salary Bands Across 58 Orgs for 11 Days

Published September 3, 202610 min read

09:14 UTC. A Zendesk ticket lands in the on-call queue, tagged security. Subject: "I can see comp bands for my whole team and I'm not a manager." Screenshot attached: the team directory page, logged in as an individual contributor, showing a column that shouldn't exist for that role at all.


the setup

people-api is the GraphQL service behind the team directory feature, an Apollo Server instance sitting in front of Postgres. Org admins can see a compBand field on each team member; everyone else gets name, title, and email. That check used to live inline in the resolver, straightforward and correct.

Eleven days earlier, a performance pass flagged people-api as one of the biggest sources of GC pauses under load. A --prof run pointed at DataLoader construction: thousands of fresh instances a second, one per request, each holding its own closure and internal cache that got garbage collected a few hundred milliseconds later. An engineer traced it to createContext, saw a brand-new DataLoader built on every request, and reasoned that requests hitting the same org didn't need to redo that work. The fix shipped as a one-line-feeling change: memoize the loader per org ID in a module-level map.


the scramble

First theory: a frontend permission bug, the UI rendering a column it should be hiding for this role. On-call opened the network tab on the reporter's session. The raw GraphQL response body already contained compBand for every team member. Whatever this was, it wasn't a rendering problem, the API itself was handing the field out.

Second theory: a bad JWT, the token carrying the wrong role claim for this user. On-call decoded it directly.

decoded access token
{
  "sub": "usr_88213",
  "org_id": "org_4471",
  "role": "member",
  "exp": 1788123456
}

Role was correctly "member," not "admin." The token was fine. Whatever was leaking the field, it wasn't happening at the auth layer.

Third theory: someone had deleted the redaction check in a recent resolver change. On-call pulled up the resolver file directly, expecting to find the guard clause missing.

people-api/src/resolvers/orgMembers.ts
orgMembers: async (_parent, { ids }, context) => {
  const loader = getOrgMemberLoader(context.orgId, context.user.role);
  return loader.loadMany(ids);
},

The check was there, just one level down, inside getOrgMemberLoader. Nothing obviously wrong at the call site. Three theories, three dead ends, and the field was still showing up in fresh sessions on every retry.


the hunt

With the resolver itself clean, the next stop was the loader function it was calling into. That's where the GC-pressure fix from eleven days ago lived.

people-api/src/context.ts, current version
const orgMemberLoaders = new Map();

function getOrgMemberLoader(orgId, role) {
  if (!orgMemberLoaders.has(orgId)) {
    orgMemberLoaders.set(orgId, new DataLoader(async (ids) => {
      const rows = await db.orgMembers.findByIds(orgId, ids);
      return rows.map((row) => (role === 'admin' ? row : redactCompBand(row)));
    }));
  }
  return orgMemberLoaders.get(orgId);
}

The map is keyed on orgId alone. The role argument only matters the first time a given org's loader gets created, because that's the only time the batch function itself gets constructed, closure and all, everything after that reuses the same instance and the same baked-in redaction decision, no matter who's calling.

To confirm, on-call added a counter around the batch function and pulled the last four hours of invocations for org_4471 from Datadog.

Datadog, custom metric
people_api.org_member_loader.batch_fn_invoked{org_id:org_4471}
  05:02 UTC  1 invocation
  05:02–09:14 UTC  0 invocations, 214 loadMany() calls served from cache

One real database fetch at 05:02, then 214 requests over the next four hours served entirely from the cached loader without ever running the batch function again. Whoever made that 05:02 request decided, for everyone at that org, whether comp bands would be visible for the rest of that server process's life.

access log, 05:02 UTC
05:02:11 org_4471 usr_10029 role=admin query=orgMembers status=200

An admin loaded the team directory at 05:02 to check a headcount number. That request warmed the loader with unredacted rows. Every teammate who opened the same page afterward, on whichever pod happened to still be holding that cached instance, got the admin's view back.


the find

Root cause: a GC-pressure fix moved DataLoader construction from per-request, inside Apollo's context factory, to a module-level map keyed only by orgId. Field-level redaction was implemented inside the batch function's closure instead of the resolver, so it ran exactly once per org, whenever the first request happened to arrive, and that first caller's role determined what every subsequent caller received for as long as that server process kept the loader cached. Non-admin requests weren't failing an authorization check, they were never running one, they were reading a response shaped by someone else's permissions.


the fix

Stop-the-bleeding move first: a rolling restart of every people-api pod, which cleared every cached loader instance and forced the next request per org to rebuild from a clean state. That bought time, not a fix, the same bug would recur the moment two requests with different roles hit the same org within one process lifetime.

The actual fix separates the two things that had gotten fused together: batching, which is safe to cache because raw rows don't vary by caller, and redaction, which depends entirely on who's asking and has to run fresh every time. The loader goes back to fetching only, no role logic inside it at all, and the resolver applies the field mask itself, per request, regardless of whether the underlying rows came from a warm cache or a fresh query.

people-api/src/context.ts, corrected
export function createContext({ req }) {
  return {
    user: req.user,
    orgId: req.user.orgId,
    loaders: {
      orgMembers: new DataLoader(async (ids) => {
        return db.orgMembers.findByIds(req.user.orgId, ids);
      }),
    },
  };
}
people-api/src/resolvers/orgMembers.ts, corrected
orgMembers: async (_parent, { ids }, context) => {
  const rows = await context.loaders.orgMembers.loadMany(ids);
  return rows.map((row) =>
    context.user.role === 'admin' ? row : redactCompBand(row)
  );
},

The loader is created fresh in the context factory again, so it never outlives a single request, and the redaction that had briefly lived inside the batch function moved out entirely. Even if someone reintroduces per-org caching later for a real performance reason, this shape survives it, because authorization no longer depends on which request happened to build the cache.

The GC pressure that started all this got solved separately and correctly: batching multiple orgMembers calls within a single request using DataLoader's normal per-tick batching window, instead of trying to share instances across requests. That cut allocation counts without ever touching who gets to see what.


the aftermath

11 days The caching change was live before the report
58 orgs Had an admin and a non-admin query the same warm loader
340 Non-admin API responses that included compBand
0 Auth checks that ran incorrectly, because none ran at all

Affected orgs got a direct notice naming the exact field exposed and the exact window. No other people-api field was affected, the redaction bug was specific to compBand, the only field whose visibility ever depended on the caller instead of the data.

  • Authorization logic doesn't belong inside anything that gets cached or shared. The moment a cache outlives the request that populated it, so does whatever access decision got baked in alongside the data.
  • DataLoader's own docs are explicit that instances should be created per request. That guidance reads like a performance note until you notice it's also a security boundary.
  • A missing field in the browser only proves the bug isn't in the browser. The network tab settled this in thirty seconds and ruled out an entire theory before anyone touched the backend.
  • "Reduce GC pressure by caching X across requests" is worth a second reviewer any time X sits anywhere near a per-user authorization decision, not just a performance sign-off.

billing-api and auth-svc use their own DataLoader instances, built the same per-request way this one used to be. Neither was touched by this change and neither saw a single leaked field.

Share this
← All Posts10 min read