Signing in through a browser cannot establish a session. The server authenticates correctly and sets a correct cookie; the client then tests for that cookie in a way that can never succeed, and bounces straight back to /login.
This blocks every authenticated surface — composer, queue, settings, accounts, inbox, discover — so it is the blocker for "finished and usable this week".
What happens
POST /api/loginreturns 200 with a token payload, and sets:set-cookie: auth-token=…; Path=/; Max-Age=2591999; HttpOnly; SameSite=Laxlogin.stx'sstoreTokenAndEnterrunsuseCookie('auth-token', { maxAge }).set(token), thenlocation.assign('/composer')./composerrenderslayouts/app.stx, whose pre-paint guard runsif (!isNativeApp && !/(?:^|;\s*)auth-token=/.test(document.cookie)) window.location.replace('/login').Back to
/login. Repeat forever.
Why it cannot work
HttpOnly means JavaScript can neither read nor write that cookie. Three client-side sites assume it can:
| Site | Code | Actual behaviour |
|---|---|---|
resources/views/login.stx, storeTokenAndEnter | useCookie('auth-token', { maxAge }).set(token) | Silent no-op. The browser discards a JS write to a name held by an HttpOnly cookie. |
resources/layouts/app.stx, auth guard | `!/(?:^ | ;\s*)auth-token=/.test(document.cookie)` |
resources/layouts/app.stx, cookie→bearer bridge | document.cookie.match(/(?:^|;\s*)auth-token=([^;]+)/)?.[1] | Always undefined, so no Authorization header is attached and every /api/ call is unauthenticated. |
HttpOnly is not configurable — @stacksjs/auth/dist/cookie.js builds the header with the flag as a literal:
parts = [
`${cookieName(options)}=${encodeURIComponent(token)}`,
`Path=${options.path ?? configured.path ?? "/"}`,
`Max-Age=${options.maxAge ?? configured.maxAge ?? defaultMaxAge()}`,
"HttpOnly",
`SameSite=${options.sameSite ?? configured.sameSite ?? "Lax"}`,
]Evidence
A beforeunload listener recorded document.cookie at the instant storeTokenAndEnter navigated. Only the CSRF token was present — auth-token was never written:
cookieAtUnload: "X-CSRF-Token=d5456fee…"Writing the exact string useCookie serialises fails, while an unrelated name written seconds earlier succeeds:
document.cookie = 'probe-raw=1; path=/; max-age=60' // sticks
document.cookie = `auth-token=${token}; path=/; max-age=2592000; SameSite=Lax` // does NOT stickThe server session is fine; it is only unreachable from the client. With the cookie in a curl jar the API still rejects the call, because the Auth middleware wants a Bearer header — which is exactly what the bridge was supposed to supply from the cookie it cannot read:
curl -b jar.txt /api/ot/subscribers
→ {"error":"Unauthorized","message":"Unauthorized. No token or session provided.","status":401}Why nobody noticed
The desktop shell does not go through any of this. allowsNativeAppWithoutLogin (app/Support/Auth/native.ts) skips the token entirely for x-ot-native: craft on a loopback, non-production host:
curl -H "x-ot-native: craft" /api/ot/subscribers → {"ok":true,"data":{"subscribers":[],…}}
curl /api/ot/subscribers → 401So the app works when driven from the desktop shell against a dev server, and is unusable in a browser. Anyone testing the former would not see this.
Fix directions
- Let the API accept the HttpOnly cookie as a session. The cookie is already correct and already carries the session; the server should honour it on
/api/requests. This deletes all three client-side workarounds and the bridge with them. Recommended. - Have the client ask instead of sniff. A
/api/me(or a server-rendered flag) decides signed-in state, rather than inspecting a cookie it cannot see. Fixes the guard andstoreTokenAndEnter; the bridge still needs (1). - Drop HttpOnly. Works, and gives up the XSS protection the framework deliberately added. Not recommended, and it needs an upstream change since the flag is hardcoded.
Whichever is chosen, storeTokenAndEnter's useCookie(...).set() should go — it has never done anything.
Reproducing
buddy migrate:fresh --force && buddy seed # seeds dev@theopentimes.org
buddy dev --frontend
buddy dev --apiSign in at /login with the seeded account. The POST returns 200; the page returns to /login with no auth-token in document.cookie.