Skip to content

fix(session): stop repeating the credentials in the body that sets the cookies - #153

Merged
Bccorb merged 1 commit into
mainfrom
fix/strip-session-material-from-bodies
Sep 7, 2026
Merged

fix(session): stop repeating the credentials in the body that sets the cookies#153
Bccorb merged 1 commit into
mainfrom
fix/strip-session-material-from-bodies

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Five handlers that issue session cookies returned the upstream body unchanged, and that body
carries the access token and the refresh token, because it is the same body
issueSessionCookies reads them out of to build the cookies. Every completed sign-in
answered with Set-Cookie: httpOnly and then handed the same two values to the caller as
JSON.

Confirmed by running it, not by reading it

Invoking the real handlers against a mocked upstream, before the change:

oauth callback  -> {"message":"Success","token":"ACCESS-TOKEN-SECRET",
                    "refreshToken":"REFRESH-TOKEN-SECRET","sub":"user-123",...}
finishLogin     -> {"message":"Success","token":"ACCESS-TOKEN-SECRET",
                    "refreshToken":"REFRESH-TOKEN-SECRET","sub":"user-123",...}
finishRegister  -> status 204, body undefined

After:

oauth callback  -> {"message":"Success","sub":"user-123","roles":["user"],
                    "email":"person@example.com","organizationId":null,
                    "returnTo":"https://app.example/dashboard","ttl":900,"refreshTtl":3600}
cookie          -> still carries token "ACCESS-TOKEN-SECRET"

Why it matters, and where it stops

The httpOnly flag on those cookies exists to keep the tokens out of reach of page scripts.
A response body is not: it is readable by anything that can see the response, and it reaches
places a cookie does not, including a devtools or HAR export shared while debugging, a
service worker, a browser extension with request access, an APM tool that records payloads,
and a proxy configured to log bodies. The refresh token is the durable session credential, so
it is the half that matters.

Being fair about the ceiling: an attacker has to already be able to observe that specific
response, nothing here persists the value, and the cookie itself was never exposed. This
restores a defence rather than closing an open door.

The correct implementation already existed

finishRegisterHandler answers 204 with no body at all. The five that did not are
finishLoginHandler, verifyLoginOtpHandler, finishOAuthLoginHandler,
pollMagicLinkConfirmationHandler and switchOrganizationHandler. Both the Express and
Fastify adapters route through these same core handlers, so both were affected.

A blanket 204 was not an option: these bodies carry fields callers do read. Only token
and refreshToken are removed, through one exported helper rather than five copies, so the
handlers cannot drift apart on it again. message, sub, email, roles, phone,
organizationId, ttl, refreshTtl and returnTo all survive, and the cookies are
untouched.

Blast radius

Nothing in @seamless-auth/react reads .token or .refreshToken off a response body: zero
matches across that package. Its result types already declare them absent, with the comment
that sessions are carried by cookies so adopters have no reason to handle raw tokens. So the
wire was contradicting the first-party client's own stated contract.

It is still a wire change for an adopter reading those fields directly against that guidance,
which is why this is minor on all three packages rather than a patch.

Tests

A new test enumerates the cookie-issuing handlers in one list and asserts three things of
each: no token in the body, the fields callers read still present, and the access token still
in the cookie. A sixth handler is covered by adding a line rather than by remembering the
invariant exists.

pnpm test across the workspace: 448 tests pass, up from 433. No existing test needed
changing, which is itself the evidence that the leak was incidental rather than intended.

…e cookies

Five handlers that issue session cookies returned the upstream body unchanged,
and that body carries the access token and the refresh token, because it is the
same body issueSessionCookies reads them out of to build the cookies. Every
completed sign-in answered with Set-Cookie httpOnly and then handed the same two
values to the caller as JSON.

The httpOnly flag exists to keep those tokens away from page scripts. A response
body is not: it reaches a devtools or HAR export shared while debugging, a
service worker, an extension with request access, an APM tool that records
payloads, a proxy logging bodies. The refresh token is the durable session
credential, so it is the half that matters.

finishRegisterHandler already had this right, answering 204 with no body.
finishLoginHandler, verifyLoginOtpHandler, finishOAuthLoginHandler,
pollMagicLinkConfirmationHandler and switchOrganizationHandler did not.

Only token and refreshToken are removed, through one exported helper rather than
five copies, so the handlers cannot drift apart on it again. message, sub, email,
roles, phone, organizationId, ttl, refreshTtl and returnTo all survive, and the
cookies are unchanged.

The new test enumerates the cookie-issuing handlers so a sixth is covered by
adding a line rather than by remembering the invariant exists.
@Bccorb
Bccorb merged commit e24dd78 into main Sep 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant