Introduction
On Better Auth servers that register the Expo plugin, an unauthenticated attacker who knows only a user's email address could sign in as that user and change the account's email address, with no action required from the victim. The fix shipped on 3 November 2026, but the vendor is explicit that upgrading is only half of the recovery: any signature the vulnerable endpoint already handed out stays valid until the application secret is rotated.
Better Auth is an open-source authentication framework built around a simple core, with plugins that add further authentication capabilities, and it emphasizes framework and platform independence. The project announced in July 2026 that it was joining Vercel. It also maintains Auth.js (formerly NextAuth.js), so its maintainers are responsible for more than one authentication project. The sources we reviewed do not quantify its user base or market share. The @better-auth/expo package is its Expo integration, and the official guide has developers add the expo() plugin to their Better Auth server, which is exactly where this flaw lives.
As of 4 November 2026, the advisory and CVE record we reviewed do not establish exploitation in the wild, a named threat actor, a confirmed public exploit, or victim counts, and CISA KEV status was not conclusively established. That is an intelligence gap, not evidence of safety. The documented unauthenticated capability is reason enough to act.
Technical Information
Root cause: a public signing oracle
The expo() plugin exposes a public GET route, /expo-authorization-proxy, beneath the auth base path. Its job is to help a system browser initiate social sign-in. It accepts an authorizationURL query parameter and an optional oauthState parameter.
In the vulnerable code, when oauthState is absent, the handler parses authorizationURL, reads the nested state query parameter from it, and stores that value with ctx.setSignedCookie(...) using ctx.context.secret. At no point does it check that the server actually issued that state. Whatever the caller puts in state comes back signed with the application secret in a Set-Cookie header.
Why one HMAC caused the damage
On its own, a signed state cookie sounds harmless. The problem is key and algorithm reuse. According to the advisory, the cookie signature is an HMAC SHA256 keyed with the application secret, and that is the same computation used for:
- HS256 JWT signatures on email verification and email change tokens
- the compact session cookie cache signature
So the endpoint was effectively a signing oracle: an attacker chooses a value, the server signs it, and the signature is accepted in a completely different authentication context. A valid signature only proves the server computed it, not that the value came from the intended workflow. Here the vulnerable server itself supplied the signature to the attacker.
Attack flow
Based on the advisory's description, exploitation proceeds as follows:
- The attacker targets a server that registers
expo()from an affected version. No other setting is required for the signing flaw. - The attacker builds an
authorizationURLwhose nestedstatequery parameter contains the payload they want signed, for example the material for an email change token, or a session cookie cache value. - The attacker sends an unauthenticated
GETto<auth base path>/expo-authorization-proxywithoutoauthState. - The handler signs the attacker-chosen
statewith the application secret and returns it in a cookie. - Because the HMAC SHA256 output is also a valid HS256 JWT signature and cookie cache signature, the attacker uses it to forge an accepted artifact.
The documented outcomes:
- Email-based takeover (no optional settings): an unauthenticated attacker who knows a user's email address can sign in as that user and change the account's email address, with no victim action.
- Cookie cache impersonation (conditional): if the session cookie cache is enabled, an attacker who knows a user's ID can act as that user.
Additional findings in the same advisory
The advisory also covers two issues that are not prerequisites for the signing flaw:
- The proxy redirected to arbitrary external HTTPS sites, allowing phishing links that begin on the authentication origin.
- Wildcard
trustedOriginsentries could match unintended URLs. This only applies where a wildcard is configured.
Scoring and classification
The CNA score is 9.1 (Critical) with vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N: network access, low complexity, no privileges, no user interaction, high confidentiality and integrity impact. NVD displays the CNA score without an independent NIST assessment and notes the record is not prioritized for enrichment. The CVE record lists CWE 346 (Origin Validation Error), while the advisory lists CWE 287, CWE 345 and CWE 601. The advisory's metadata also still shows "No known CVE" despite the published CVE record.
Patch Information
Better Auth fixed this in @better-auth/expo 1.7.16 (and 1.8.0-beta.5 on the beta line). The fix shipped with the matching better-auth core release through release PR #12198, "fix: harden authentication flows", merged on 3 November 2026. The v1.7.16 release notes describe the Expo change as a fix for the authorization proxy "signing a state the server did not issue and redirecting to hosts that are not configured providers." The advisory tells users to upgrade both @better-auth/expo and better-auth, because the Expo fix depends on a helper newly exported from core.
Fix 1: sign only server-issued, unexpired state
Commit eb3da3d, "fix(expo): only sign a state the server issued in the authorization proxy". When signIn.social starts an OAuth flow, Better Auth already stores a verification record keyed auth-state:<state>. The fix looks up that record before minting the signed cookie:
-import { HIDE_METADATA } from "better-auth"; +import { getAuthStateVerificationIdentifier, HIDE_METADATA } from "better-auth"; ... + // Only sign a state this server issued and still holds. + const verification = + await ctx.context.internalAdapter.findVerificationValue( + getAuthStateVerificationIdentifier(state), + ); + if (!verification || new Date(verification.expiresAt) < new Date()) { + return ctx.redirect(authorizationURL); + } const stateCookie = ctx.context.createAuthCookie("state", { maxAge: 5 * 60, // 5 minutes });
If the state is unknown or expired, the endpoint still redirects but sets no cookie. Legitimate flows that rewrite state (for example through oAuthProxy) keep working, and an attacker gets nothing signed back. The same commit exports getAuthStateVerificationIdentifier from better-auth, which is why both packages must be upgraded together.
New regression tests cover the edge cases:
- An issued state receives the
better-auth.state=<state>.cookie. - An invented 32-character state (
Zx9fA1bC2dE3fG4hI5jK6lM7nO8pQ9rS) yields a nullset-cookie. - An issued state replayed after 11 minutes, past the 10-minute record lifetime, also receives no cookie.
Fix 2: redirect only to provider authorization origins
Commit a530bdf, "fix(expo): redirect the authorization proxy only to configured provider origins", replaces an old FIXME and closes the open redirect. A new helper, isProviderAuthorizationOrigin, runs before anything else:
+ if (!(await isProviderAuthorizationOrigin(ctx, url))) { + throw new APIError("BAD_REQUEST", { + message: "Invalid authorizationURL", + }); + }
- Social and generic OAuth providers: the plugin builds each configured provider's authorization URL with placeholder values (
state: "state",codeVerifier: "code-verifier") and takes its origin. Results are cached in aWeakMapper provider. Failures are not cached, so the check fails closed (HTTP 400, no redirect, no cookies). - SSO providers: these live in the database and have no fixed endpoint. A new
afterhook on/sign-in/ssorecords the issued authorization URL's origin underexpo-authorization-origin:<state>for 10 minutes, but only when the URL ishttps:, carries a state, and passesctx.context.isTrustedOrigin. The proxy accepts an SSO origin only if it matches the origin recorded for that exact state. URLs returned by other sign-in routes, such as/sign-in/email-link, are rejected.
Operational note: if Expo sign-in through an SSO identity provider stops working after the upgrade, add the IdP's origin to trustedOrigins.
Fix 3: stricter wildcard trusted origins
This fix is in core and shipped in the same release. Per the advisory, wildcard trustedOrigins entries such as myapp://* or https://*.example.com now match on the parsed scheme, host and path, and no longer match URLs containing userinfo or URLs that cannot be parsed. Wildcard hosts made only of digits, dots and *, such as 192.168.*.*, now match only IPv4 addresses. We did not review a standalone diff for this part, so this summary relies on the advisory text. Separately, the Expo integration docs say development wildcard patterns should be used only in development, with the app's specific scheme in production.
What the patch cannot undo: rotate the secret
The patch stops new forged signatures from being issued. It cannot invalidate ones already minted, because they remain valid HMACs under the same secret. The advisory, MITRE and NVD all state that if the endpoint was reachable, the application secret must be rotated after upgrading. The order matters: upgrade first, then rotate. Rotation alone leaves the oracle open; the upgrade alone leaves earlier attacker-obtained signatures trusted.
Interim options if you cannot upgrade yet
- Remove the
expo()server plugin if you do not use Expo social sign-in. - Otherwise, filter requests to
/expo-authorization-proxywhoseauthorizationURLcontains astatethat is not exactly 32 characters drawn from uppercase letters, lowercase letters, digits, the hyphen and the underscore. States issued by Better Auth always have this form.
The vendor warns that the format filter addresses only the signing problem. It does not fix the redirect or wildcard origin findings.
Detection Methods
There are no public Sigma, YARA, Suricata/Snort or Nuclei rules for this CVE, and no published IoCs such as IPs, domains or hashes. Useful detection comes from understanding the endpoint and logging the one parameter an attacker must control.
1. Exposure check
You are affected if your server registers the expo() plugin from an affected @better-auth/expo version. No other setting is needed. Check lockfiles or your SBOM against the ranges in the next section, then confirm that the server-side auth config actually calls expo(). A dependency that is installed but never registered does not expose the route. SCA tools that ingest GitHub Security Advisories should flag the versions by GHSA ID (GHSA 4wq4 2ppv q3hg).
2. What the attack looks like on the wire
The attack is a GET to <auth base path>/expo-authorization-proxy with an authorizationURL query parameter and no oauthState. The malicious payload sits in the state parameter nested inside the encoded authorizationURL, and the server returns a signature for it in Set-Cookie.
3. Detection rule: validate the nested state shape
The vendor's workaround doubles as a detection signature. Legitimate states always match:
[A-Za-z0-9_-]{32}
To apply this in a WAF, reverse proxy or log pipeline:
- Match
GETrequests whose path ends in/expo-authorization-proxy. - URL decode the
authorizationURLquery parameter. Thestateis inside the encoded URL, not a top-level parameter, so naive parameter matching will miss it. - Parse the decoded URL's query string and extract
state. - Alert if
stateis missing, repeated, longer or shorter than 32 characters, or contains any character outside the allowed set.
Forged values will usually stand out: a JWT-style or cookie cache payload is much longer than 32 characters and typically contains . separators. As the vendor notes, this check covers only the signing attack, not the open redirect.
4. Secondary signal: open redirect abuse
Build an allowlist of the identity provider hosts your app actually uses, and alert on /expo-authorization-proxy requests whose decoded authorizationURL host is not on it. Hits may indicate phishing links that start on your auth origin.
5. Retroactive hunting
Because signatures issued before the fix stay valid until secret rotation, abuse before the patch can still have effects. The following steps are our inference from the advisory's impact description, not vendor-published procedures:
- Replay the state shape check over historical access logs for the endpoint, as far back as retention allows.
- Look for account email address changes or email verification completions with no matching user-initiated request flow.
- If the session cookie cache is enabled, look for authenticated activity where the user ID in the cookie does not match a session in your database.
- Correlate anomalous proxy requests by source IP and timing with those account events. A malformed-state proxy call followed shortly by an email change or session event is a strong sign of compromise.
Affected Systems and Versions
The advisory gives more precise ranges than the CVE description:
@better-auth/expo:>= 1.4.0-beta.6, < 1.7.16, fixed in1.7.16@better-auth/expo:>= 1.8.0-beta.0, < 1.8.0-beta.5, fixed in1.8.0-beta.5better-auth:< 1.7.16, fixed in1.7.16better-auth:>= 1.8.0-beta.0, < 1.8.0-beta.5, fixed in1.8.0-beta.5
Vulnerable configuration: the server must register the expo() plugin. The advisory does not describe every installation of the core package as exposing the endpoint. The email-based takeover requires no further setting. The user ID impersonation path additionally requires the session cookie cache to be enabled, and the wildcard origin finding requires a wildcard trustedOrigins entry.
Vendor Security History
A separate Critical Better Auth advisory, GHSA 965c 763c 88jm, published 30 September 2026, allowed OAuth state to be accepted as a Magic Link token. It affected versions >= 1.4.0-beta.18, < 1.7.7 under specific Magic Link, provider and database state configurations, letting an attacker complete sign-in for an email address without proving mailbox access. The fix was 1.7.7, and the advisory warned that disabling new registrations does not protect existing accounts.
That is a distinct vulnerability, not evidence that CVE-2026-121802 was exploited, and its exposure conditions differ. The common thread is authentication artifacts being accepted outside their intended purpose. On the response side, the November 3, 2026 security release shipped 1.7.16 and 1.8.0-beta.5 addressing 12 security advisories, credited private reporters, and instructed users to update all Better Auth packages together.
References
- NVD entry for CVE-2026-121802
- CVE.org record for CVE-2026-121802
- GitHub advisory GHSA 4wq4 2ppv q3hg: Authorization proxy can sign values it did not issue
- GitHub API view of the advisory
- Expo package CHANGELOG
- Release v1.7.16
- Release v1.7.16 (API)
- Release PR #12198: harden authentication flows
- PR #12198 (API)
- Commit eb3da3d: only sign a state the server issued
- Commit eb3da3d (API)
- Commit a530bdf: redirect only to configured provider origins
- Commit a530bdf (API)
- Vulnerable routes.ts source
- Better Auth security release, November 3, 2026
- GHSA 965c 763c 88jm: OAuth state usable as a Magic Link
- Better Auth security advisories list
- Better Auth security documentation
- Better Auth options reference
- Better Auth Expo integration guide
- Better Auth is joining Vercel
- Auth.js is now part of Better Auth
- VulDB entry for CVE-2026-121802
- CISA Known Exploited Vulnerabilities Catalog



