Today Platform Web — Dev Docs
Architecture

Better-Auth quirks

The `oauthProviderClient` plugin auto-injects `oauth_query` into non-GET request bodies. What that means in practice and the workaround for calls that shouldn't progress the OAuth flow.

better-auth (catalog better-auth: 1.6.5) backs the authentication client in apps/web and apps/admin. The repo's config has one non-obvious behavior worth documenting — the auto-injection done by @better-auth/oauth-provider.

The plugin chain

apps/web/src/lib/better-auth-client.ts sets up the auth client with several plugins:

export const authClient = createAuthClient({
  baseURL: betterAuthConfig.baseURL,
  plugins: [
    emailOTPClient(),
    organizationClient(),
    twoFactorClient({
      /* ... */
    }),
    multiSessionClient(),
    oauthProviderClient(), // ← the one with the quirk
    lastLoginMethodClient(),
  ],
  fetchOptions: { credentials: 'include' },
})

The auto-injection

oauthProviderClient() registers an onRequest hook that:

  1. Inspects window.location.search for a ?sig=... parameter
  2. If found, parses it into a signed-OAuth-query payload
  3. Injects that payload into the body of every non-GET request as oauth_query: parseSignedQuery(window.location.search)

The signal this carries to the server is: "the user is mid-OAuth flow, complete it." Server responds with:

{ "data": { "redirect": true, "url": "https://..." } }

Then better-auth's core redirectPlugin synchronously runs window.location.href = data.url. The result is a hard navigation away from the current page.

When it matters

The intended use case — the user is on /oauth/select-account after Google or GitHub redirected them back, the URL has ?sig=<signed-payload>, and clicking "Continue" should hand control to the auth server to finish minting the access token.

The accidental case — the user is on a ?sig=... URL for some other reason (a stale tab, a copy-pasted URL), and any non-GET call (logout, profile update, session refresh) accidentally triggers the redirect.

Symptoms when this fires unintentionally:

  • A page flashes briefly, then the browser hard-navigates somewhere unexpected (usually a callback URL)
  • React Query mutations report success but the user is yanked elsewhere
  • Stack traces show oauthProviderClient.onRequest immediately before window.location.href = ...

The workaround

For sign-in calls that explicitly should not progress the OAuth flow (e.g. logging back in from /login while a stale ?sig= is in the URL), temporarily clear the query string for the duration of the call:

const restorePath = `${window.location.pathname}${window.location.hash}`
window.history.replaceState(null, '', restorePath)
try {
  await authClient.signIn.emailOtp({
    /* ... */
  })
} finally {
  // Restore the original URL so subsequent navigation works correctly
  // window.history.replaceState(null, '', originalUrl)
  // — usually the navigation that follows replaces it anyway
}

This makes the injection hook read an empty window.location.search, so the auto-redirect doesn't fire. The successful sign-in then navigates via router.push(callbackURL).

The session-cache race

A related issue: after a successful signIn.emailOtp, the React Query session cache may still hold a null session from before sign-in. The next layout / route that gates on session sees null, decides the user is unauthenticated, and bounces back to /login.

Fix: invalidate and await the session query before navigating:

await authClient.signIn.emailOtp({
  /* ... */
})
await queryClient.invalidateQueries({ queryKey: authKeys.session() })
router.push(callbackURL)

The await is the key — without it, router.push fires before the session re-fetches.

Why we tolerate the auto-injection

The oauthProviderClient plugin's auto-injection is the simplest way to implement the headless auth server pattern — the server-side oauth_query parsing is correct, and 99% of the time the user's URL state matches the auth state. Removing the auto-injection would mean every page that participates in OAuth would need to manually attach oauth_query to its requests, which is fragile.

So the contract is: the URL is the OAuth state. If you're not actively in an OAuth flow, clear the ?sig= query before making auth-state-changing requests.

On this page