Skip to content

Access token embeds full group documents and can exceed the 16 KiB header limit (HTTP 431) #1534

Open

Description

@gdevenyi

What Happened?

The login access token contains the full document of each of the user's groups. The API runs on Fastify with the Node default maxHeaderSize of 16 KiB. That limit applies to all request headers together. When the headers exceed it, the API rejects every authenticated request with 431 Request Header Fields Too Large.

The cause is in three places:

  1. UsersService.findByUsername loads full Group documents with include: { groups: true } (users.service.ts#L164).
  2. AuthService.login copies those documents into the token payload as groups: user.groups (auth.service.ts#L64). The Omit<TokenPayload, 'permissions'> annotation is a type only. It removes nothing at runtime.
  3. Neither main.ts nor libnest's FastifyAdapter options set maxHeaderSize.

Each group document holds these fields, and no limit applies to them:

Field Token cost Notes
userIds 36 bytes per user User create and update write it with groups: { connect } and groups: { set } (users.service.ts#L99-L101, #L190-L192), which update both sides of the relation.
accessibleInstrumentIds 89 bytes per instrument SHA-256 IDs.
emailTemplates about 1.3 bytes per character of subject and body, in each language GroupEmailTemplate is a composite type, so it is stored in the group document (schema.prisma#L120-L125, #L149). $AuthoredLocalizedString sets no maximum length (core.ts#L106-L109).

Estimated token size (HS256, a group manager, ObjectId user IDs, SHA-256 instrument IDs, the CASL rules of createForPayload; each email template has a 1500-character body in English and in French):

Groups Users per group Instruments per group Email templates Token size
1 10 40 0 6.5 KiB
1 100 40 0 9.7 KiB
1 300 40 0 16.7 KiB
1 10 100 0 11.7 KiB
1 10 150 0 16.1 KiB
1 10 40 3 18.8 KiB
3 30 40 0 18.2 KiB
1 1 0 4 19.1 KiB

The other request headers use about 0.5 to 1 KiB, so the limit for the token is about 15 KiB. One field alone reaches it at about 250 users in a group, or at about 140 accessible instruments in total across the user's groups, or with a few long email templates. A user in more than one group carries the fields of all of those groups.

What Did You Expect?

Token size should not depend on the number of users, instruments or email templates in a group.

Operating System

n/a (server-side)

Steps to Reproduce

  1. Create a group. Add 4 email templates to it, each with a 1500-character body in English and in French. The token is then about 19 KiB, even when the group has no accessible instruments.
  2. Log in as a group manager in that group.
  3. Load any page. The first authenticated API request returns HTTP 431.

Anything Else?

Correction to the first version of this issue. The first version named Group.subjectIds as the cause. That is not correct for data that the current code writes. SessionsService.create calls SubjectsService.addGroupForSubject, which pushes to Subject.groupIds only (subjects.service.ts#L19-L28). On MongoDB, Prisma does not update the other side of a many-to-many relation for a scalar-list push, and no current code writes Group.subjectIds (schema.prisma#L143-L144). Thus, more subjects do not make the token larger.

A database that older versions wrote to can still hold a large subjectIds. This query shows the array sizes on a real deployment:

db.GroupModel.aggregate([{ $project: { name: 1,
  subjectIds: { $size: { $ifNull: ['$subjectIds', []] } },
  userIds: { $size: { $ifNull: ['$userIds', []] } },
  accessibleInstrumentIds: { $size: { $ifNull: ['$accessibleInstrumentIds', []] } },
  emailTemplates: { $size: { $ifNull: ['$emailTemplates', []] } },
  bytes: { $bsonSize: '$$ROOT' } } }])

Verification status. I computed the token sizes above with the script below. It builds the same payload shape as AuthService.login for a group manager, and signs it with HS256. I confirmed the rejection against a bare Fastify 5 server on Node: a 15.5 KiB bearer token returns 200, and a 44.7 KiB token returns 431. I have not yet reproduced the full login flow against a running ODC instance.

token-size.mjs: run with node token-size.mjs (no dependencies)
import { createHash, createHmac, randomBytes } from 'node:crypto';

const b64 = (value) => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
const objectId = () => randomBytes(12).toString('hex');
const sha256 = () => createHash('sha256').update(randomBytes(16)).digest('hex');
const now = new Date().toISOString();

function group({ instruments, templates, users }) {
  const body = 'x'.repeat(1500);
  return {
    accessibleInstrumentIds: Array.from({ length: instruments }, sha256),
    activeAssignmentEmailTemplateId: null,
    createdAt: now,
    emailTemplates: Array.from({ length: templates }, () => ({
      body: { en: body, fr: body },
      id: randomBytes(16).toString('hex'),
      name: 'Reminder',
      subject: { en: 'Your questionnaire', fr: 'Votre questionnaire' }
    })),
    id: objectId(),
    instrumentRepoIds: [],
    name: 'Memory Clinic',
    settings: {
      defaultIdentificationMethod: 'PERSONAL_INFO',
      idValidationRegex: null,
      idValidationRegexErrorMessage: null,
      minimumAge: null,
      subjectIdDisplayLength: null
    },
    subjectIds: [],
    type: 'CLINICAL',
    updatedAt: now,
    userIds: Array.from({ length: users }, objectId)
  };
}

function token(groups) {
  const groupIds = groups.map((g) => g.id);
  const inGroups = { in: groupIds };
  const hasSome = { hasSome: groupIds };
  const permissions = [
    { action: 'manage', conditions: { groupId: inGroups }, subject: 'Assignment' },
    { action: 'manage', conditions: { id: inGroups }, subject: 'Group' },
    { action: 'read', subject: 'Instrument' },
    { action: 'create', subject: 'Instrument' },
    { action: 'delete', conditions: { seriesGroupId: inGroups }, subject: 'Instrument' },
    { action: 'read', conditions: { groupIds: hasSome }, subject: 'InstrumentRepo' },
    { action: 'create', subject: 'InstrumentRecord' },
    { action: 'create', conditions: { groupId: inGroups }, subject: 'InstrumentRecordFile' },
    { action: 'read', conditions: { groupId: inGroups }, subject: 'InstrumentRecord' },
    { action: 'read', conditions: { groupId: inGroups }, subject: 'InstrumentRecordFile' },
    { action: 'create', subject: 'Session' },
    { action: 'read', conditions: { groupId: inGroups }, subject: 'Session' },
    { action: 'create', subject: 'Subject' },
    { action: 'read', conditions: { groupIds: hasSome }, subject: 'Subject' },
    { action: 'read', conditions: { groupIds: hasSome }, subject: 'User' }
  ];
  const payload = {
    additionalPermissions: [],
    basePermissionLevel: 'GROUP_MANAGER',
    exp: 1_800_003_600,
    firstName: 'Jane',
    groups,
    iat: 1_800_000_000,
    id: objectId(),
    lastName: 'Smith',
    mustResetPassword: false,
    permissions,
    username: 'jsmith'
  };
  const unsigned = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}`;
  return `${unsigned}.${createHmac('sha256', 'secret').update(unsigned).digest('base64url')}`;
}

const kib = (n) => `${(n / 1024).toFixed(1)} KiB`;
const rows = [
  ['1 group: 10 users, 40 instruments', [{ instruments: 40, templates: 0, users: 10 }]],
  ['1 group: 100 users, 40 instruments', [{ instruments: 40, templates: 0, users: 100 }]],
  ['1 group: 300 users, 40 instruments', [{ instruments: 40, templates: 0, users: 300 }]],
  ['1 group: 10 users, 100 instruments', [{ instruments: 100, templates: 0, users: 10 }]],
  ['1 group: 10 users, 150 instruments', [{ instruments: 150, templates: 0, users: 10 }]],
  ['1 group: 10 users, 40 instruments, 3 templates', [{ instruments: 40, templates: 3, users: 10 }]],
  ['3 groups: each 30 users, 40 instruments', Array(3).fill({ instruments: 40, templates: 0, users: 30 })],
  ['1 group: 1 user, 0 instruments, 4 templates', [{ instruments: 0, templates: 4, users: 1 }]]
];
for (const [label, specs] of rows) {
  console.log(`${label}: ${kib(token(specs.map(group)).length)}`);
}

const base = token([group({ instruments: 40, templates: 0, users: 10 })]).length;
const perUser = (token([group({ instruments: 40, templates: 0, users: 1010 })]).length - base) / 1000;
const perInstrument = (token([group({ instruments: 1040, templates: 0, users: 10 })]).length - base) / 1000;
console.log(`per user: ${perUser.toFixed(1)} B, per instrument: ${perInstrument.toFixed(1)} B`);

Output:

1 group: 10 users, 40 instruments: 6.5 KiB
1 group: 100 users, 40 instruments: 9.7 KiB
1 group: 300 users, 40 instruments: 16.7 KiB
1 group: 10 users, 100 instruments: 11.7 KiB
1 group: 10 users, 150 instruments: 16.1 KiB
1 group: 10 users, 40 instruments, 3 templates: 18.8 KiB
3 groups: each 30 users, 40 instruments: 18.2 KiB
1 group: 1 user, 0 instruments, 4 templates: 19.1 KiB
per user: 36.0 B, per instrument: 89.3 B

Proposed fix. Do not put whole group documents in the token.

  • Short term: in findByUsername, use include: { groups: { select: { ... } } } with only the fields that apps/web reads from the token: id, name, settings, type, accessibleInstrumentIds and instrumentRepoIds. This removes userIds, subjectIds and emailTemplates. Use an explicit select, not omit, so that a new large field does not go into the token by accident. This fix keeps accessibleInstrumentIds, so about 140 instruments still reach the limit.
  • Long term: put only the group IDs in the token, and load the group data from the API. The group data in the token also stays stale until the next login, for example after a group manager changes the group settings. This change removes both problems.

A narrower TokenPayload.groups type in packages/schemas/src/auth/auth.ts breaks these call sites, which use the full Group type:

Tests. A unit test that asserts the login token payload contains no userIds, subjectIds or emailTemplates. An e2e test that logs in as a user whose group has several long email templates, then loads the dashboard. That test fails before the fix. A test with many subjects passes without the fix, so it does not test this problem.

Activity

  1. added
    BugType: existing behavior is wrong
    on Sep 11, 2026
  2. changed the title [-]Access token includes every subject ID in the user's groups and exceeds the 16 KiB header limit (HTTP 431)[/-] [+]Access token embeds full group documents and can exceed the 16 KiB header limit (HTTP 431)[/+] on Sep 11, 2026
  3. added
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    Difficulty: HighMore than a day, or touches several workspaces
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another area
    Area: Webapps/web (clinician-facing React SPA)
    PerformanceSpeed, memory or scalability
    on Sep 27, 2026
  4. joshunrau commented on Oct 26, 2026

    @joshunrau
    Collaborator

    Still present on main (d86740e). The include is now at users.service.ts:159 and the copy into the token at auth.service.ts:72. Neither line has changed since this issue was filed.

    The short-term fix reaches the limit sooner than the issue says

    accessibleInstrumentIds grows by one id per edition, not per instrument. InstrumentsService.create pushes each new edition's id into every group that holds the previous edition (instruments.service.ts:121-135), and repository sync calls the same create (instrument-repos.service.ts:282). Nothing removes the older ids. Manage Group lists only the latest edition, and each save writes back the ids it does not list as hiddenAccessibleIds (group/manage.tsx:922, :824). So the limit is about 140 editions. For example, 47 instruments on their third edition reach it, and no one has to touch the group.

    I would remove accessibleInstrumentIds and instrumentRepoIds from the token in the same change. Five routes read them from currentGroup:

    • dashboard.tsx:44
    • group/manage.tsx:857-858
    • the three callers of selectAdministrableInstruments: group/remote-assignments.tsx:79, instruments/accessible-instruments.tsx:35 and session/remote-assignment.tsx:131

    Each can use useGroupQuery(currentGroup?.id) instead, as GroupEmailTemplates.tsx:39 does. This also fixes a second bug. Every other member of the group keeps the list from their own login, so they do not see an instrument enabled after that login. If one of them is a manager and saves Manage Group, the save also removes that instrument again.

    Two gotchas:

    • selectAdministrableInstruments lets every instrument through when the group is null (administrable-instruments.ts:23), and useGroupQuery stays pending when there is no group. Pass null only when currentGroup is null. Otherwise, wait for the group data.
    • A user created through the API with basePermissionLevel: null may not have read Group, so the new query returns 403 for them. In createForPayload, grant read Group on the user's own groups at every level. The token already holds those groups, so this exposes nothing new.

    Size after the fix

    I adapted the issue's script, with groups cut to id, name, settings and type. A group manager's token is then 2.4 KiB, plus about 685 bytes per additional group. It reaches 8 KiB at about 10 groups and 15 KiB at about 20.

    With ids alone, as in the issue's long-term fix, each group still costs about 405 bytes. About 360 of them are in permissions, which repeats the group-id array in 10 group-manager rules and 4 standard rules (ability.factory.ts:58-86). Removing that is a separate decision. JwtStrategy.validate (jwt.strategy.ts:25) would rebuild the ability from the payload, and the web would need its rules from elsewhere. GROUP_SCOPED_CONDITIONS (ability.factory.ts:20-28) is typed against Prisma where-inputs, so it cannot move to packages/schemas as it is.

    The 8 KiB figure matters. nginx's default large_client_header_buffers 4 8k returns 400 for any header field over 8 KiB (nginx docs). So raising maxHeaderSize does not help when nginx sits in front of APP_PORT. Row 2 of the issue's table (9.7 KiB) is already over that limit.

    Plan

    1. packages/schemas/src/auth/auth.ts: add TokenGroup = Pick<Group, 'id' | 'name' | 'settings' | 'type'>, and type TokenPayload.groups as TokenGroup[].
    2. findByUsername: use include: { groups: { select } }. Declare the select satisfies { [K in keyof TokenGroup]: true } so the type and the query cannot drift apart. The other callers of findByUsername need only group ids (instrument-records.service.ts:506) or nothing (sessions.service.ts:92). API code that reads RequestUser.groups uses only id (instruments.service.ts:767-769).
    3. Web: type currentGroup and changeGroup with TokenGroup. A full Group still fits this type, so manage.tsx:977 and the admin switcher compile unchanged. The issue's list misses three sites that need the new type: StartSessionForm.tsx:44, utils/upload.ts:993 and the return type of useSwitchableGroups. Move the five reads to useGroupQuery. Once the type is narrower, the compiler flags each one. After the Manage Group save, set the ['group', id] cache from the response, as GroupEmailTemplates.tsx:135 does.

    Tests

    • Unit, API. The issue's test would pass without the fix, because auth.service.test.ts mocks findByUsername. Assert the select in users.service.test.ts instead.
    • Real database. apps/api/test/suites/02-user-permissions.suite.ts already logs in and decodes the payload (signedRules). Give the group an email template there. Then assert that each token group has exactly the TokenGroup keys.
    • Unit, web. Each of the five routes already has a unit test. Add a case to each one: the instrument list follows the group query, not the store.
    • E2E, login. In auth.spec.ts, next to should log in through the UI, give the group four bilingual templates of 1500 characters. Then assert that the dashboard renders.
    • E2E, stale list. In accessible-instruments.spec.ts, remove an instrument from a new group and authenticateAs a new user in that group. Then add the instrument back through api. Open the page with new AccessibleInstrumentsPage(page) and expect the card. Do not use getPageModel here, because it authenticates again as actingRole.

    Both e2e tests need a group-update helper in testing/src/support/api-client.ts. It must read updatedAt first, because $UpdateGroupData requires expectedUpdatedAt with emailTemplates.

    Related

    #1691 and #1719 change the same changeGroup(updatedGroup) call. #1719 proposes seeding the ['group', id] cache in useUpdateGroupMutation. If #1719 lands first, step 3 needs no cache write of its own. This change fixes the stale instrument list that #1719 reports, but not the stale settings. #1734 edits AuthService.login next to the user annotation that this change must retype, so whichever of the two lands second needs a rebase.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)Area: SharedCross-tier packages (schemas, react-core, etc.) not covered by another areaArea: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: HighMore than a day, or touches several workspacesPerformanceSpeed, memory or scalabilityPriority: HighSignificant or frequently hit user impact; target the next release or two

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions