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:
- Inspects
window.location.searchfor a?sig=...parameter - If found, parses it into a signed-OAuth-query payload
- 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.onRequestimmediately beforewindow.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.
Related
- Auth interaction architecture — the headless auth
server pattern that motivates
oauthProviderClient - Auth cookie current state — how the session cookie flows once OAuth completes
- Opal shader shell — the three auth surfaces
(
/login,/oauth/select-account,/oauth/consent) that use this client
Auth cookie current state
A current-state snapshot of cookie, domain, and auth relationships in today-platform-web. No target design, no migration plan.
OpalShaderShell
Three auth surfaces (`/login`, `/oauth/select-account`, `/oauth/consent`) share one shell that bundles the Opal background, the centered content column, and the beacon plumbing the brand mark needs to paint.