Skip to content

Fix mobile login for cross-site apps; keep refresh tokens in first-party HTTP-only cookies - #261

Merged
jalexw merged 6 commits into
mainfrom
claude/amazing-turing-a9j1ly
Oct 14, 2026
Merged

jalexw merged 6 commits into
mainfrom
claude/amazing-turing-a9j1ly

Conversation

@jalexw

@jalexw jalexw commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Logging in to a third-party client app failed on every iPhone with Did not receive user data from auth server after login, for example lorahworld.jalexw.ca against auth.schemavaults.com. Desktop Chrome worked.

Outside development and test, the browser SDK asked the token endpoint for refresh_token_delivery=http_only_cookie. The refresh token was then set as an HTTP-only cookie on the auth server's host, in answer to a credentialed fetch from the client's page. When the page is on another site, that is a third-party cookie. WebKit blocks third-party cookies outright, and every iOS browser is WebKit, so the cookie was dropped. The code exchange succeeded, the follow-up whoami got a 401, and handleSuccessfulAuthentication threw. Desktop Chrome still accepts third-party cookies, which is why only phones failed.

I reproduced this end to end:

  • the real auth server and the example resource server, on two HTTPS sites;
  • Chromium with third-party cookies blocked: Set-Cookie BLOCKED [ThirdPartyPhaseout] on POST /api/oidc/token, then GET /api/auth/whoami/<app> returned 401, then the same error.

What changes

Where a client app's refresh token lives (auth-react-provider): doesSupportHttpOnlyRefreshToken() is deterministic.

App Refresh token storage
The auth server's own app The auth server's HTTP-only cookie (unchanged)
Client app with session routes (the default for codegen'd apps) HTTP-only cookie on the app's own host (see below)
Any other client app Rotating refresh token in localStorage, presented as Authorization: Bearer

The auth server's own cookie is never used for a client app any more. An earlier commit on this branch guessed same-site-ness by probing which host suffixes accept cookies; commit a025d4a removed it.

Same-origin session routes (@schemavaults/auth-server-sdk/auth-session-route, written by codegen):

  • one route file each:

    Codegen route file Factory Exports
    auth/session/token/route.ts createAuthSessionTokenRoute() { POST }
    auth/session/whoami/route.ts createAuthSessionWhoamiRoute() { GET }
    auth/session/logout/route.ts createAuthSessionLogoutRoute() { POST }
  • each forwards its request to the auth server server-to-server;

  • the refresh token lives in a host-only HttpOnly; SameSite=Strict; Path=/ cookie on the app's own host, named __Host-schemavaults_refresh_token_<app> with Secure outside development and test:

    • browsers refuse a __Host- cookie that has a Domain or a narrower Path, so a sibling subdomain can't plant one. Under __Secure-, evil.example.com could set Domain=example.com; Path=/auth/session/token, and the browser would send that cookie first, so the route would present the attacker's refresh token (session fixation);
    • a request that carries the cookie name twice is treated as having no session;
    • since the prefix requires Path=/, the cookie is no longer scoped to /auth/session, and the cookie_path option is gone;
  • the refresh token is stripped from every response body;

  • cross-origin requests are refused, and the token route refuses other apps' clients and other grant types.

Client SDK configuration:

  • auth-client-sdk: the adapter's getSessionRouteUrls() returns each route's own URL (AuthSessionRoutes: { token, whoami, logout }), and AUTH_SESSION_ROUTE_PATHS names codegen's paths.
  • auth-react-provider: <AuthProvider session_routes> takes one path or URL per route. The generated auth-provider.tsx passes AUTH_SESSION_ROUTE_PATHS; null opts out.

Auth server:

  • Token endpoint fix: a refresh grant that presented its refresh token as the app's HTTP-only cookie used to get the rotated token back in the JSON body unless it asked for cookie delivery, so a script on the client's page could make the browser send the cookie and read a fresh refresh token. Such grants now always rotate the token back into the cookie. Covered by a unit test and an E2E spec.
  • Logout: POST /api/auth/logout/{client_app_id} also revokes a refresh token presented as Authorization: Bearer, but only one issued to that app. The SDK's logout uses it for localStorage tokens, and the session routes use it too.

Other SDK changes:

  • Bearer-authenticated whoami calls send no cookies, so a stale cookie can't take the Bearer token's place.
  • In session-route mode, logout also revokes any session an older SDK left in the auth server's cookie.

Build: auth-server-sdk's turbo build inputs now include the codegen templates' src/** (via $TURBO_ROOT$) and its build scripts. A template-only edit used to be a cache hit that kept the old templates in dist.

E2E: the example resource server uses the session routes. ExampleResourceServer.cy.ts now checks for the routes' HTTP-only cookie (with or without the __Host- prefix), where it used to look for a refresh token in localStorage. New specs cover cookie-presented refresh rotation and Bearer logout.

Verification

  • Browser drive with session routes. Real auth server and example app on two HTTPS sites, Chromium with third-party cookies blocked (iOS-like) and allowed (desktop-like). In both modes:
    • login, a refresh grant, the protected API call and logout all go through the three routes;
    • the session cookie is __Host-, host-only, with Path=/;
    • after logout, the auth server rejects the old refresh token;
    • a page script never obtains the refresh token: document.cookie, the token route and the auth server directly all came back without it.
  • Browser drive with a sibling subdomain. A page on evil.<parent> tried to plant both the __Host- and the __Secure- session cookie with Domain=<parent>; Path=/auth/session/token:
    • Chromium refused the __Host- one (InvalidPrefix);
    • it kept the __Secure- one and sent it first to the token route, but the route ignores that name;
    • the victim's login, refresh (POST /auth/session/token 200) and API call still worked.
  • Browser drive without session routes. Routes switched off in the example app, same two modes: login succeeds, with the refresh token in localStorage.
  • Fast checks. Typecheck passes for every package, lint for every changed package, and all unit-test tasks pass (bun run test; 106 in auth-server-sdk, including the host-only cookie and planted-duplicate cases). The example app's typecheck (after next typegen) and route-catalogue test pass with the generated routes.
  • Turbo inputs. In dry runs, editing a template changes the auth-server-sdk#build hash, and reverting the edit restores it.
  • Not run here: the Cypress E2E suite, because the Cypress binary download is blocked in the sandbox. CI will run it, including the new and updated specs.

Notes

  • Re-login after upgrading. Client apps sign in once more; old auth-server-cookie sessions are revoked the first time users are signed out.
  • Same-site apps without session routes. They move from the auth server's cookie to localStorage; adding session routes gives them an HTTP-only refresh token again.
  • Cookie sent app-wide. With Path=/, the browser sends the HTTP-only refresh cookie with every request to the app's host, not only the session routes. Page scripts still can't read it, and other sites can't trigger it (SameSite=Strict), but the app's own server code and request logs can see it.
  • Rate limit. Refresh grants proxied through the token route count against the app server's IP in the auth server's per-IP refresh limit (30 per minute).
  • Remaining exposure. A malicious script running while the page is open can still use the session, for example to get short-lived access tokens or start a new authorization flow. This is inherent to browser-based public clients; it can no longer take the refresh token away.
  • Downstream. After publishing, lorahworld needs @schemavaults/auth-react-provider@0.18.2 and @schemavaults/auth-server-sdk@0.33.4.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT

…uth-resource-server-codegen-templates:0.0.162, auth-server-sdk:0.32.10, trpc-backend-init:0.9.81, auth-server:0.55.8, e2e-auth-tests:0.12.9 - fix mobile login for apps on another site than the auth server

Logins to third-party client apps always failed on iPhones with "Did not
receive user data from auth server after login" (e.g. lorahworld.jalexw.ca
against auth.schemavaults.com), while desktop Chrome worked.

Outside development/test the React adapter asked the token endpoint for
`refresh_token_delivery=http_only_cookie`, so the refresh token was set as
an HTTP-only cookie on the AUTH SERVER's host in answer to a credentialed
fetch from the client's page. For a page on another site that is a
third-party cookie, which WebKit (Safari and every iOS browser) blocks
outright. The code exchange succeeded, the cookie was dropped, and the
follow-up whoami call got a 401, so handleSuccessfulAuthentication threw.
Desktop Chrome still accepts third-party cookies, hence "desktop only".

- auth-react-provider: `doesSupportHttpOnlyRefreshToken()` keeps the cookie
  for the auth server's own app and for pages on the same site as the auth
  server only. `isSameSiteAsAuthServer()` compares scheme + registrable
  domain, finding the page's eTLD+1 by probing which suffix of its host
  the browser accepts as a cookie Domain (the browser's own Public Suffix
  List). Cross-site pages keep the rotating refresh token in localStorage,
  the path dev/test already uses. Logout in that mode now revokes the token
  at the auth server (Bearer) and clears any cookie an older SDK left.
  Compiled tests are stripped from dist, as in auth-client-sdk.
- auth-client-sdk: Bearer-authenticated whoami calls send no cookies.
  whoami tries the app's refresh-token cookie first, so a stale cookie from
  the old SDK would answer for the session, or fail it once revoked.
- auth-server: POST /api/auth/logout/{client_app_id} also revokes a refresh
  token presented as `Authorization: Bearer`, only when issued to that app.

Apps on another site than the auth server (lorahworld included) sign in
once more after upgrading: their old cookie session is no longer read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…uth-resource-server-codegen-templates:0.0.163, auth-server-sdk:0.33.0, trpc-backend-init:0.9.82, auth-server:0.55.9, cypress-e2e-auth-tests-helper-commands:0.6.1, e2e-auth-tests:0.12.10 - same-origin session route keeps refresh tokens in first-party HTTP-only cookies

Refresh tokens of apps on another site than the auth server could not stay
in an HTTP-only cookie: the auth server's cookie is a third-party cookie
there, which WebKit (every iOS browser) drops, so the previous commit moved
them to localStorage, readable by any script on the page.

Token endpoint gap (auth-server): a refresh grant that presented its
refresh token as the app's HTTP-only cookie got the rotated token back
inline unless it asked for cookie delivery, so a page script could make the
browser send the cookie and read a fresh refresh token from the JSON body.
Such grants now always rotate the token back into the cookie.

Same-origin auth session route (auth-server-sdk/auth-session-route):
createAuthSessionRouteHandler() serves <route>/token, /whoami and /logout
on the app's own origin and forwards them to the auth server
server-to-server. It keeps the refresh token in an HttpOnly,
SameSite=Strict, Path=<route> cookie on the app's domain (__Secure- and
Secure outside development/test), strips it from every response body,
presents it as a Bearer to whoami / logout, and refuses cross-origin
requests, other apps and other grant types.

- auth-client-sdk: adapters may expose getSessionRouteUrl(); the SDK then
  sends token requests (openid-client customFetch), whoami and logout to
  the route.
- auth-react-provider: `session_route_uri` prop on <AuthProvider>; the
  adapter treats it as HTTP-only-cookie mode and logs out through it (and
  revokes any session an older SDK left in the auth server's cookie).
- codegen: writes auth/session/[action]/route.ts and the generated
  auth-provider.tsx enables it with "/auth/session" (null opts out). The
  templates gain a type-check-only devDependency on auth-server-sdk.
- E2E: the example resource server now uses the route; its spec asserts
  the HTTP-only cookie instead of a localStorage refresh token, and a new
  spec pins that a cookie-presented refresh token is never answered inline.

Verified in Chromium with third-party cookies blocked and allowed, against
the real auth server and example app on two HTTPS sites: login, a refresh
grant, the protected API call and logout (token revoked) all go through
the route, and a page script gets no refresh token by any path.

Known limit: refresh grants proxied through the route count against the
app server's IP in the auth server's per-IP refresh rate limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
@jalexw jalexw self-assigned this Oct 13, 2026
…uth-resource-server-codegen-templates:0.0.164, auth-server-sdk:0.33.1, trpc-backend-init:0.9.83, auth-server:0.55.10 - one route file per auth session route, each with its own client path

The auth session route was a single catch-all (`auth/session/[action]/route.ts`)
whose handler parsed the action from the URL, and the client SDK derived each
route as `<base>/<action>` from one `/auth/session` base.

- auth-server-sdk: `createAuthSessionTokenRoute()` → `{ POST }`,
  `createAuthSessionWhoamiRoute()` → `{ GET }` and
  `createAuthSessionLogoutRoute()` → `{ POST }`, sharing
  `createSessionRoute()` (same-origin check, config, session cookie,
  upstream fetch). Next.js now routes methods (and answers 405) itself. The
  cookie's Path is still the routes' common parent by default; a new
  `cookie_path` option covers routes that share no parent.
  `createAuthSessionRouteHandler()` (never published) is gone.
- codegen: writes `auth/session/token/route.ts`, `whoami/route.ts` and
  `logout/route.ts` instead of the `[action]` catch-all.
- auth-client-sdk: the adapter's `getSessionRouteUrls()` returns each
  route's own URL (`AuthSessionRoutes`: `{ token, whoami, logout }`) in place
  of a base URL; `AUTH_SESSION_ROUTE_PATHS` names codegen's paths.
- auth-react-provider: `session_routes` (one path or URL per route) replaces
  `session_route_uri`; the generated `auth-provider.tsx` passes
  `AUTH_SESSION_ROUTE_PATHS` (`null` opts out).

Re-verified in Chromium with third-party cookies blocked and allowed against
the real auth server and example app on two HTTPS sites: login, refresh,
protected API call and logout all go through the three routes, and page
scripts get no refresh token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…uth-resource-server-codegen-templates:0.0.165, auth-server-sdk:0.33.2, trpc-backend-init:0.9.84, auth-server:0.55.11 - drop same-site cookie probing; client apps use session routes or localStorage

ReactAuthClientSdkAdapter decided whether a client app could keep its
refresh token in the auth server's HTTP-only cookie by guessing whether the
page and the auth server are the same site: it found the page's registrable
domain by writing throwaway cookies on ever-shorter host suffixes and seeing
which ones the browser accepted. That is fragile, and with the same-origin
session routes it is no longer needed.

doesSupportHttpOnlyRefreshToken() is now deterministic: true for the auth
server's own app and for apps with session routes, false for every other
client app (which keeps its rotating refresh token in localStorage, as it
already did on another site and in development/test). The auth server's own
cookie is never used for a client app any more: for an app on another site
it is a third-party cookie, which WebKit drops. A same-site app without
session routes moves from that cookie to localStorage; session routes give
it an HTTP-only refresh token again.

Removes same-site-as-auth-server.ts and its test (and auth-react-provider's
now test-less `test` script: `bun test` fails without tests).

Verified in Chromium with third-party cookies blocked and allowed against the
real auth server and example app on two HTTPS sites, both with the session
routes (default) and with them switched off (localStorage): logins succeed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
… - rebuild auth-server-sdk when the codegen templates change

auth-server-sdk's build copies the codegen templates' source into its dist
(copy-codegen-templates.sh), but its turbo build inputs only listed its own
src/**, package.json and tsconfig.json. A template-only edit (without the
conventional auth-server-sdk version bump) was a turbo cache hit that kept the
old templates in dist.

The build inputs now also list the templates' src/** (via $TURBO_ROOT$) and the
build's own scripts (copy-codegen-templates.sh, prepare-cli.sh). Verified with
turbo dry runs: editing a template changes the auth-server-sdk#build hash, and
reverting the edit restores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…, e2e-auth-tests:0.12.11 - __Host- session cookie so sibling subdomains cannot plant one

The session routes kept the refresh token in `__Secure-schemavaults_refresh_token_<app>`
with `Path=/auth/session`. `__Secure-` still allows a `Domain` attribute, so a host
controlling a sibling subdomain could plant the same name with `Domain=<parent>;
Path=/auth/session/token`; the browser sends the more specific cookie first and
`readCookie` took the first match, presenting the attacker's refresh token
(session fixation).

- The cookie is now `__Host-schemavaults_refresh_token_<app>` (Secure, `Path=/`,
  no `Domain`) outside development/test: browsers refuse a `__Host-` cookie that
  carries a `Domain` or a narrower `Path`, so no other host can set it.
- `readCookie` treats a name sent more than once as no session instead of
  guessing which copy is the route's own.
- The `cookie_path` route option is gone (the prefix requires `Path=/`).
- The E2E session-cookie assertion accepts the `__Host-` form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
@jalexw
jalexw merged commit aef0cdd into main Oct 14, 2026
35 checks passed
@jalexw
jalexw deleted the claude/amazing-turing-a9j1ly branch October 14, 2026 15:29
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.

2 participants