Skip to content

fix(core): answer 401, not 400, when nobody is signed in - #149

Merged
Bccorb merged 2 commits into
mainfrom
fix/signed-out-answers-401
Aug 30, 2026
Merged

fix(core): answer 401, not 400, when nobody is signed in#149
Bccorb merged 2 commits into
mainfrom
fix/signed-out-answers-401

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #148, transferred here from seamless-auth-api#153 because the handler
lives in this package.

Where it actually was

The report came in against the auth API, observed through the /auth mount in
seamless-idea-api. It is not that repository: the API answers 401 for a
missing or invalid bearer token on every protected route, and the string in the
report appears nowhere in it. The 400 is ensureCookies, here.

GET /auth/users/me   400  {"error":"Missing required cookie \"seamless-access\""}
GET /generations     401  {"error":"Failed to find authentication token required"}

The change

ensureCookies gates every access-required route. When the required cookie is
absent and there is no refresh cookie to fall back on, refreshRequiredCookie
returns null. That is exactly the signed-out case, and it answered 400. The
request is perfectly well formed. There is simply no session, which is what 401
means.

The two branches either side of it already answered 401, for a refresh that
failed and for a cookie that is invalid or expired. This one was the outlier
rather than the convention, so the status a signed-out visitor got depended on
which way their session happened to be absent. All three now agree. The response
body is unchanged, since it was already accurate.

Why it is worth the behaviour change

Every signed-out page view produced a 400, so 400 became ordinary background
traffic in adopter logs. The consuming product has just added telemetry, and this
is the shape of thing that hides a real malformed request once somebody is
watching.

Consumers also turn status codes into sentences. 400 asks a product to say
"that request did not come through in a form we could use" when the true sentence
is "you have been signed out, sign in again", and from outside there was no way
to tell which had happened.

Blast radius

Adopter-visible, which is why the changeset is a minor under the pre-1.0 policy
in RELEASES.md. Anything branching on 400 from an /auth route to detect a
malformed request will now see 401 for a signed-out visitor.

I checked the dependents rather than assuming:

  • seamless-auth-react does not branch on response status for this.
  • seamless-auth-admin-dashboard keeps a set of statuses whose upstream message
    it surfaces verbatim, {400, 404, 409, 422}, and uses friendly wording for auth
    statuses. A signed-out request there stops surfacing
    Missing required cookie "access" to an operator and starts getting the
    signed-out wording, which is the improvement the issue asked for.
  • seamless-idea-api and seamless-idea-web have their own 400s, none of them
    reading this one.

The change is in core, and both adapters pass the status straight through, so
Express and Fastify pick it up without adapter changes.

Not changed

ensureCookies has a second 400, for when the adapter supplied no cookie name
for a route's requirement. That is a misconfiguration rather than a bad request,
so 500 would arguably be the honest status, but it is a setup-time error rather
than the runtime noise this issue is about and I left it alone rather than widen
the change. Worth a separate issue if you want it.

Checks

pnpm build and pnpm test pass at the workspace root: core 227 tests, express
150, fastify 44, all suites green.

Two tests pinned the old status, each with a comment presenting the 400 as
intended. Both now pin 401, and the express one keeps its real assertion that
the request is never forwarded upstream. Added coverage for a pre-auth route
reached with no cookies, and for the refresh-failed branch, so all three
signed-out paths are pinned to agree.

Bccorb added 2 commits August 30, 2026 17:36
ensureCookies gates every access-required route. When the required cookie was
absent and there was no refresh cookie to fall back on, which is exactly the
signed-out case, it answered 400. The request was perfectly well formed. There
was simply no session, and that is what 401 means.

Two things went wrong with the old status. Every signed-out page view produced
400s, so 400 became ordinary background traffic in adopter logs and would hide a
real malformed request from anyone watching. And consumers translate status
codes into words for readers: 400 asks a product to say "that request did not
come through in a form we could use" when the true sentence is "you have been
signed out, sign in again", and there was no way to tell the two apart from
outside.

The neighbouring branches in the same function already answered 401 for a
refresh that failed and for a cookie that was invalid or expired, so this branch
was the outlier rather than the convention. All three now agree, and the status
no longer depends on which way the session happened to be absent. The response
body is unchanged.

Two tests pinned the old status, both with a comment explaining the 400 as
intended behaviour. Both now pin 401, and the express one keeps its real
assertion that the request is never forwarded upstream.

Closes #148
…xpress

The parity suite already drove a session-less request through both adapters, but
it only asserted that the two agree, not what they agree on, so it passed just as
happily when both answered 400. The status itself is what a consumer reads to
tell "sign in again" from "that request was not understood", so it is now pinned
by value on an access-gated route and a pre-auth gated one, along with the fact
that neither adapter asks upstream anything.

Checked against the previous behaviour: both new cases fail with 400 received
where 401 was expected, so this catches the regression rather than merely
restating the fix.
@Bccorb

Bccorb commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Asked whether this reaches Fastify as well as Express. It does, and I have now
pinned it there rather than leaving it inherited.

Both adapters read the status straight off the core result:

// packages/express/src/middleware/ensureCookies.ts:80
res.status(result.status ?? 401).json({ error: result.errorCode });

// packages/fastify/src/hooks/ensureCookies.ts
return reply.status(result.status ?? 401).send({ error: result.errorCode });

So the one change in core covers both, and neither adapter needed editing.

What was missing was coverage. The parity suite already drove a session-less
request through both adapters, but it only asserted that the two agree, not what
they agree on, so it passed just as happily when both answered 400. Nothing on
the Fastify side pinned the value at all.

Added a parity case that pins 401 by value on an access-gated route
(/organizations) and a pre-auth gated one (/webAuthn/login/start), and
asserts neither adapter asks upstream anything. Verified it catches the
regression by reverting the core change: both cases fail with 400 received where
401 was expected.

Fastify is now 46 tests, and pnpm build plus pnpm test pass across the
workspace: core 227, express 150, fastify 46.

@Bccorb
Bccorb merged commit 4d94e41 into main Aug 30, 2026
2 checks passed
@Bccorb
Bccorb deleted the fix/signed-out-answers-401 branch August 30, 2026 21:48
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.

GET /auth/users/me answers 400 for a missing session, where the rest of the API answers 401

1 participant