Here is a permission check that looks responsible and quietly breaks pagination:

projects: async (_, { first, after }, ctx) => { const { rows } = await ctx.db.query( 'SELECT * FROM projects WHERE id > $1 ORDER BY id LIMIT $2', [after ?? 0, first + 1], ); const visible = rows.filter((p) => canSee(ctx.userId, p)); // permission check return toConnection(visible, first); } 

The query fetches a page, then the resolver removes what the viewer may not see. Nothing leaks in the response. Each row is checked. Code review usually lets it through.

I measured what it does to a real user.

The setup

PostgreSQL 16, 10,000 projects. The viewer is a member of 50 of them, picked at random. The client asks for first: 20 and keeps paging while hasNextPage is true, like any Relay or Apollo client. I ran three versions of the resolver.

Resolver Projects the user saw Requests Rows read from Postgres
Filter after LIMIT, hasNextPage from the filtered rows 0 of 50 1 21
Filter after LIMIT, hasNextPage from the raw rows 50 of 50 500 10,499
Filter in SQL with a membership join 50 of 50 3 52

Version 1: the list is empty

The viewer's first project had id 226. The first page held projects 1 to 20, the viewer could see none of them, so visible was empty. With hasNextPage computed from visible, the answer was false.

The client rendered "You have no projects yet" for a user with 50. There was no error, nothing in the logs, and it only happens to users whose projects aren't among the first rows in the table. That's most of your users, once the table grows.

Version 2: correct, and 500 round trips

Computing hasNextPage from the raw rows fixes the empty list. Now the client walks every page of the whole table to find the user's 50 projects: 500 requests, 10,499 rows read. 453 of the 500 pages came back empty. In a UI that loads a page per scroll, the user scrolls through hundreds of blank pages.

It also leaks. Any totalCount field computed before the filter tells the viewer there are 10,000 projects, and the cursor positions reveal where the hidden rows sit.

The fix: put the permission in the query

projects: async (_, { first, after }, ctx) => { const userId = requireUser(ctx); const { rows } = await ctx.db.query( `SELECT p.*, m.role FROM projects p JOIN project_members m ON m.project_id = p.id AND m.user_id = $1 WHERE p.id > $2 ORDER BY p.id LIMIT $3`, [userId, after ?? 0, first + 1], ); return toConnection(rows, first); } 

Now LIMIT applies to rows the viewer can see, so every page is full until the last one: 20, 20, 10. Three requests, 52 rows read. EXPLAIN ANALYZE put the first page at 0.17 ms of execution time.

Two details make it hold up:

  • Index the lookup direction. The membership primary key is usually (project_id, user_id). This query starts from the user, so add CREATE INDEX ON project_members (user_id).
  • Return the role from the same query. The resolver gets the viewer's role per project for free, so nested fields don't need another membership lookup per row.

Test it with a user who isn't first in the table

The tests that miss this bug seed one user who owns everything. Seed a viewer whose projects are scattered, then walk every page:

it('pages through all of a viewer\'s projects with full pages', async () => { const ids = await seedProjects({ total: 200, memberOf: 30, viewer: bo }); // scattered const seen: string[] = []; let after: string | null = null; do { const res = await gql(`query($after: String) { projects(first: 10, after: $after) { edges { node { id } } pageInfo { hasNextPage endCursor } } }`, { after }, bo); const conn = res.data.projects; if (conn.pageInfo.hasNextPage) expect(conn.edges).toHaveLength(10); // no short pages seen.push(...conn.edges.map((e) => e.node.id)); after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null; } while (after); expect(seen.sort()).toEqual(ids.sort()); }); 

Checklist

  • List queries filter by permission in SQL, never with rows.filter(...) afterwards.
  • LIMIT and hasNextPage are computed on rows the viewer can see.
  • Counts come from the same filtered query.
  • The membership table has an index starting with user_id.
  • One test walks every page as a viewer whose rows are scattered.

Making Claude Code write it this way

Ask Claude Code for "a projects query that only returns projects the user can see" and the post-filter version is a common answer, because it reads clearly. This rule is part of the resolver-auth skill in my Claude Code skills pack for TypeScript + GraphQL + Postgres backends. It also covers NOT_FOUND vs FORBIDDEN and checks on nested fields.