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 addCREATE 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. -
LIMITandhasNextPageare 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.
- Two skills are free (GraphQL N+1 fixes and safe Postgres migrations): github.com/adnan909/free-sample
- The full pack, with the resolver-auth skill, 6 more skills and a starter backend tested in CI: Backend Skills Pack on Gumroad

