November 3rd, 2026·5 min read

Account takeover in @better-auth/expo

TL;DR: Strix found a critical, zero-click account-takeover vulnerability in @better-auth/expo, Better Auth's Expo integration. An unauthenticated attacker could get the application to sign a forged token, then use it to change a victim's email and obtain their session. No password, inbox access, or victim interaction required. We reported the issue to Better Auth and coordinated disclosure for their November 3 security release (GHSA-4wq4-2ppv-q3hg).

Better Auth is an open-source TypeScript framework that developers put in their backend to handle sign-up, login, and sessions. Vercel acquired the company in July 2026. Its Expo integration brings that authentication to React Native apps.

In October alone, npm recorded 50.8 million downloads of better-auth and 5.4 million of @better-auth/expo.

For the email-change attack, knowing the victim's email address was enough, no user interaction was required.

This started by us just giving Strix the repository. Then one of its agents followed a cookie-signing helper into that mobile integration and found a public endpoint that would sign whatever state value the caller supplied.

The next step was finding somewhere else that would accept the signature.

A login helper with access to the signing key

Applications using the server-side @better-auth/expo plugin expose an endpoint called /expo-authorization-proxy. Its job is to help with OAuth login: set a state cookie, then redirect the user to the identity provider.

OAuth state ties a login response to the flow that started it. Here, the proxy took an authorization URL from the caller, extracted its state parameter, and signed that value with the application's secret. It checked that state existed, but never checked that Better Auth had issued it.

These are the relevant lines from the code Strix reviewed, with the cookie setup omitted:

1const state = url.searchParams.get("state");
2// ...
3await ctx.setSignedCookie(
4 stateCookie.name,
5 state,
6 ctx.context.secret,
7 stateCookie.attributes,
8);

No login was required to call this endpoint. The signature came straight back in the response's Set-Cookie header.

This is a signing oracle: the attacker chooses the data, and the server supplies a valid signature. The secret stays on the server the whole time.

The agent captured the connection in its closing report:

JWT & Crypto · 04:49 UTC
Agent analysis · closing report

state is only checked for non-emptiness, so this is a signing oracle over arbitrary bytes with the raw app secret — the same secret used for HS256 JWTs (crypto/jwt.ts) […]

Same key, different meaning

The root agent had specifically asked its crypto specialist to investigate whether signatures could cross between features:

Root Agent · 04:09 UTC
Investigation plan

is the JWT signing key derived from the app secret with a distinct purpose/context (HKDF info string) so that a token minted by another subsystem (session cookie signature, OAuth state, email token) is not valid as a JWT and vice versa?

Strix traced the secret into Better Auth's email-verification flow. The tokens accepted there were HS256 JWTs: a header, a payload, and an HMAC-SHA256 signature. The cookie helper used HMAC-SHA256 too, with the same application secret.

That gave Strix a way to make the two operations sign exactly the same bytes:

1Cookie: HMAC-SHA256(app_secret, state)
2JWT: HMAC-SHA256(app_secret, encoded_header + "." + encoded_payload)

Put the JWT's header and payload into state, ask the proxy to sign it, and convert the returned signature to the base64url encoding a JWT expects. The result is a token the email-verification endpoint will accept.

For the proxy, those bytes were an OAuth state value. For the email handler, they were permission to change someone's account.

Strix built a token naming victim@corp.example and requesting an email change to attacker@evil.example. Then it made the two requests: one to get the signature, one to submit the completed token to /verify-email.

Here's the agent's recorded output:

JWT & Crypto · 04:33 UTC
Signing oracle → email verification
"oracle status": 302,
"state cookie found": true,
"oracle signed value equals signing input": true,
"verify-email status": 200
Email changed; session issued
{
  "status": true,
  "user": {
    "name": "Victim",
    "email": "attacker@evil.example",
    "emailVerified": true,
    "id": "1caVeDWSnHDABIJnzpeqc5DHbxoayxEQ"
  }
}

Set-Cookie: better-auth.session_token=…; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax

Trace excerpts are shortened and formatted for readability.

The victim's email is now the attacker's, emailVerified is true, and the server has issued a session cookie.

The handler looked up the account using the email in the token, applied the requested change, and created a session. Strix had reached the end of the email-change flow without ever receiving a verification email.

The signature check passed because the server had signed the token itself.

A session that never existed

Strix then took the same idea to another feature: Better Auth's optional session cookie cache.

With caching enabled, the server can read session data from a signed cookie instead of looking it up in the database on every request. In the compact cache strategy, that signature also used the application secret.

This session-cache forgery does not apply to the jwe strategy: it uses an HKDF-derived encryption key, so the proxy cannot forge those cache tokens. The reproduction below uses compact.

So Strix invented a session. It literally called it forged-session-id, attached the victim's identity, and used the proxy to obtain signatures for both the cache payload and a matching session-token cookie. Then it sent the cookies to /get-session.

JWT & Crypto · 04:34 UTC
Compact session-cache reproduction
"oracle exact": true,
"get-session status": 200

{
  "session": {
    "id": "forged-session-id",
    "userId": "xsIArUbXQmhRtYXAaijHeffphLVvYzH7"
  },
  "user": {
    "id": "xsIArUbXQmhRtYXAaijHeffphLVvYzH7",
    "email": "victim2@corp.example",
    "emailVerified": true
  }
}

The agent's closing report explains why that 200 mattered:

JWT & Crypto · 04:49 UTC
Agent analysis · closing report

Chain 2 (executed): forge the compact cookie cache plus a signed session_token for an attacker-chosen token → GET /get-session returned the victim's session from the cache with no DB lookup (neither the session id nor the token exists).

This path worked without configuring email verification.

An endpoint built to help mobile users log in was now supplying signatures for two separate account-takeover paths.

The check was on the redirect

The proxy already had security checks around its destination URL: it rejected malformed URLs, non-HTTPS destinations, same-origin destinations, and fragments.

Those checks still allowed an ordinary external HTTPS URL carrying attacker-chosen state. The attacker could collect the signature from the 302 response without following the redirect at all.

The signing operation needed its own boundary: state tied to a login flow the server had actually created. Separate signing keys for OAuth state, email tokens, and session data would also stop a signature obtained from one feature from being accepted by another.

Coordinated disclosure

Better Auth accepted our report and included it in their coordinated November 3 security release. The vulnerability was also independently identified by the Better Auth team while preparing this security release, as they investigated a separate report on the same endpoint. Those findings are also part of the November 3 release.

Huge thanks to the Better Auth team for their quick response and for being awesome to work with throughout the disclosure. The advisory is published as GHSA-4wq4-2ppv-q3hg, and the fix ships in better-auth and @better-auth/expo 1.7.16 (1.8.0-beta.5 on the beta line). See their security release notice for details.

In our investigation, Strix discovered and validated the vulnerability autonomously; the only human involvement was messaging the Better Auth team to coordinate disclosure.