Record what the browser matrix measured, and what it changed

The R1 mechanism was reasoned from the code and checked on Chromium. Running it
on two genuinely different registrable domains with real certificates, across
Chromium, Firefox, WebKit and Safari 27, shows the arrangement working
everywhere except Firefox's opt-in block-all, and working by two different
mechanisms.

Chromium, WebKit and Safari refuse the server's pair, because AUTH_SAME_SITE
defaults to emitting no SameSite attribute and a cross-site cookie without one
is rejected; that refusal is what lets the widget's partitioned pair land.
Firefox accepts it, and since that cookie is HttpOnly the browser then forbids
the widget's script from replacing it, so Firefox holds no partitioned copy and
rides on an ordinary third-party cookie even with the flag on. Safari blocks
third-party cookies with nothing configured, which makes AUTH_SAME_SITE=none
alone already broken there rather than deprecated.

It also changes what the upstream Partitioned work is worth, which this section
had written off twice. Building go-pkgz/auth with the option and running the
same matrix gives the same persistence with the JWT still HttpOnly: on Safari,
document.cookie inside the frame returns the token under the header flag and
does not under the partitioned build. The header path's XSS cost is avoidable,
not inherent, so both paths are better with the upstream change even though
neither depends on it.
This commit is contained in:
Dmitry Verkhoturov
2026-08-24 01:10:04 +01:00
parent a0680b11ea
commit 10c5b158db
@@ -109,6 +109,41 @@ it is partitioned. #2214's control cookie encodes exactly that: an unpartitioned
cookie written by the same script in the same frame has to be dropped, or the case declares itself
vacuous.
**Measured across four engines, and the mechanism is not uniform.** The above was reasoned from the
code and verified on Chromium. Driving the real thing on two genuinely different registrable domains
with real certificates, across Chromium, Firefox, WebKit and Safari 27, shows the recommended
arrangement working everywhere except one case, and working for two different reasons.
On Chromium, WebKit and Safari the server's own pair is refused, because `AUTH_SAME_SITE` defaults
to
emitting no `SameSite` attribute at all and a cross-site cookie without one is rejected. That
refusal
is what clears the field for the widget's partitioned pair, which is what survives. The absence of
the attribute is doing work here, which is worth stating because it reads like an unset default.
On Firefox the server's pair is accepted, and since the `JWT` it sets is `HttpOnly`, the browser
then
forbids the widget's script from replacing a cookie of that name. So Firefox never holds a
partitioned
copy and the session rides on an ordinary unpartitioned third-party cookie even with the flag on. It
works, and it stops working the moment the reader blocks those. Firefox's own default, Total Cookie
Protection, partitions rather than blocks and is therefore fine; its opt-in "block all third-party
cookies" discards partitioned cookies too, so no configuration survives it and nothing in remark42
can change that.
Safari is the sharp end: it blocks third-party cookies with nothing configured, so
`AUTH_SAME_SITE=none`
on its own has already stopped working there for every reader. The old recipe is not deprecated, it
is broken, which is the strongest argument for treating R1 as live work rather than a documented
limitation.
**That changes what the upstream `Partitioned` work is worth.** Building `go-pkgz/auth` with a
`PartitionedCookies` option and running the same matrix gives the same persistence with the server's
`JWT` still `HttpOnly`: on Safari, `document.cookie` inside the frame returns the token under the
header flag and does not under the partitioned build, while both keep the reader signed in. So the
two routes are not equivalent, and the header path's XSS cost is avoidable rather than inherent.
`go-pkgz/auth` #318 carries that change with the measurements behind it.
The documentation gap this section named is closed by #2218. The separate-domain manual now
recommends `AUTH_SEND_JWT_HEADER=true` and carries the XSS trade-off in the same breath, and the
parameter page no longer promises `SameSite=Strict` cookies and a `__Host-` prefix that
@@ -595,9 +630,13 @@ subscription flows. R1 costs Path B less than an earlier version of this line cl
server-rendered fragment has no client-side writer and therefore needs the upstream `Partitioned`
work, which contradicts Path B's own design, since the task list below retains the existing auth
through `htmx:configRequest`. `request` in `fetcher.ts` already turns `X-JWT` into the partitioned
pair, so Path B can keep that writer or attach the same handling to a response hook. The upstream
work is mandatory only for a server-set, `HttpOnly`, default-on solution, which is a different
choice from the one Path B is making.
pair, so Path B can keep that writer or attach the same handling to a response hook.
The upstream work is still the better answer for either path, and for a reason measurement supplied
rather than argument: with `PartitionedCookies` the server sets the pair itself and the `JWT` stays
`HttpOnly`, so the session survives third-party blocking without the token ever becoming readable
from script. The header route buys the same persistence by giving that readability away. So Path B
does not depend on the upstream change, and both paths are better with it.
On one contributor the realistic figure is long enough that the plan's own warning applies to the
schedule and not only to the design. Deletes the entire npm toolchain. **Against it**: 2,400 lines
of the most stateful code get rewritten; a second HTML-fragment API surface becomes permanent