Repository navigation
Description
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:
UsersService.findByUsernameloads full Group documents withinclude: { groups: true }(users.service.ts#L164).AuthService.logincopies those documents into the token payload asgroups: user.groups(auth.service.ts#L64). TheOmit<TokenPayload, 'permissions'>annotation is a type only. It removes nothing at runtime.- Neither
main.tsnor libnest'sFastifyAdapteroptions setmaxHeaderSize.
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
- 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.
- Log in as a group manager in that group.
- 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, useinclude: { groups: { select: { ... } } }with only the fields thatapps/webreads from the token:id,name,settings,type,accessibleInstrumentIdsandinstrumentRepoIds. This removesuserIds,subjectIdsandemailTemplates. Use an explicitselect, notomit, so that a new large field does not go into the token by accident. This fix keepsaccessibleInstrumentIds, 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:
- auth.service.ts#L39-L41 (
user: User & { groups: Group[] }) - store/types.ts#L14-L15 (
changeGroup,currentGroup: Group) - GroupSwitcher.tsx#L61
- auth.slice.ts#L12
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
[-]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)[/+]joshunrau commentedon Oct 26, 2026
Still present on
main(d86740e). The include is now atusers.service.ts:159and the copy into the token atauth.service.ts:72. Neither line has changed since this issue was filed.The short-term fix reaches the limit sooner than the issue says
accessibleInstrumentIdsgrows by one id per edition, not per instrument.InstrumentsService.createpushes each new edition's id into every group that holds the previous edition (instruments.service.ts:121-135), and repository sync calls the samecreate(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 ashiddenAccessibleIds(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
accessibleInstrumentIdsandinstrumentRepoIdsfrom the token in the same change. Five routes read them fromcurrentGroup:dashboard.tsx:44group/manage.tsx:857-858selectAdministrableInstruments:group/remote-assignments.tsx:79,instruments/accessible-instruments.tsx:35andsession/remote-assignment.tsx:131Each can use
useGroupQuery(currentGroup?.id)instead, asGroupEmailTemplates.tsx:39does. 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:
selectAdministrableInstrumentslets every instrument through when the group isnull(administrable-instruments.ts:23), anduseGroupQuerystays pending when there is no group. Passnullonly whencurrentGroupis null. Otherwise, wait for the group data.basePermissionLevel: nullmay not haveread Group, so the new query returns 403 for them. IncreateForPayload, grantread Groupon 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
groupscut toid,name,settingsandtype. 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 topackages/schemasas it is.The 8 KiB figure matters. nginx's default
large_client_header_buffers 4 8kreturns 400 for any header field over 8 KiB (nginx docs). So raisingmaxHeaderSizedoes not help when nginx sits in front ofAPP_PORT. Row 2 of the issue's table (9.7 KiB) is already over that limit.Plan
packages/schemas/src/auth/auth.ts: addTokenGroup = Pick<Group, 'id' | 'name' | 'settings' | 'type'>, and typeTokenPayload.groupsasTokenGroup[].findByUsername: useinclude: { groups: { select } }. Declare the selectsatisfies { [K in keyof TokenGroup]: true }so the type and the query cannot drift apart. The other callers offindByUsernameneed only group ids (instrument-records.service.ts:506) or nothing (sessions.service.ts:92). API code that readsRequestUser.groupsuses onlyid(instruments.service.ts:767-769).currentGroupandchangeGroupwithTokenGroup. A fullGroupstill fits this type, somanage.tsx:977and the admin switcher compile unchanged. The issue's list misses three sites that need the new type:StartSessionForm.tsx:44,utils/upload.ts:993and the return type ofuseSwitchableGroups. Move the five reads touseGroupQuery. Once the type is narrower, the compiler flags each one. After the Manage Group save, set the['group', id]cache from the response, asGroupEmailTemplates.tsx:135does.Tests
auth.service.test.tsmocksfindByUsername. Assert the select inusers.service.test.tsinstead.apps/api/test/suites/02-user-permissions.suite.tsalready logs in and decodes the payload (signedRules). Give the group an email template there. Then assert that each token group has exactly theTokenGroupkeys.auth.spec.ts, next toshould log in through the UI, give the group four bilingual templates of 1500 characters. Then assert that the dashboard renders.accessible-instruments.spec.ts, remove an instrument from a new group andauthenticateAsa new user in that group. Then add the instrument back throughapi. Open the page withnew AccessibleInstrumentsPage(page)and expect the card. Do not usegetPageModelhere, because it authenticates again asactingRole.Both e2e tests need a group-update helper in
testing/src/support/api-client.ts. It must readupdatedAtfirst, because$UpdateGroupDatarequiresexpectedUpdatedAtwithemailTemplates.Related
#1691 and #1719 change the same
changeGroup(updatedGroup)call. #1719 proposes seeding the['group', id]cache inuseUpdateGroupMutation. 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 editsAuthService.loginnext to theuserannotation 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.