# Account takeover in @better-auth/expo

Unauthenticated /expo-authorization-proxy signed arbitrary OAuth state with the app secret, letting attackers forge HS256 email-verification JWTs and compact session-cache cookies to take over accounts.

- Payout: Research
- Severity: critical
- Program: Better Auth
- Write-up by: Strix
- Disclosed: 2026-11-03
- Write-up quality: 9/10
- Original: https://www.strix.ai/blog/better-auth-expo-signing-oracle
- Library page: https://bugbountylibrary.com/r/blog%3Astrix-ai-blog-better-auth-expo-signing-oracle
- Tags: area:web3 domain:web impact:ato kind:research platform:research sev:critical tech:jwt tech:nodejs technique:chained vuln:account-takeover vuln:auth-bypass vuln:crypto

**Target:** /expo-authorization-proxy endpoint in @better-auth/expo server plugin
**Root cause:** The proxy signed any caller-supplied OAuth state value with the raw application secret without verifying Better Auth issued it, and the same secret was reused for HS256 email-verification JWTs and compact session-cache cookies with no purpose separation.
**Steps:**
1. Send a request to `/expo-authorization-proxy` with an authorization URL whose `state` parameter contains `base64url(header).base64url(payload)` of a forged JWT naming the victim and requesting an email change.
2. Read the signed value from the `Set-Cookie` header in the 302 response; convert the signature to base64url to complete the JWT.
3. Submit the completed token to `/verify-email`; server accepts it, changes the victim's email to the attacker's, sets `emailVerified: true`, and issues a session cookie.
4. Alternatively, use the same oracle to sign a compact session-cache payload (e.g. `forged-session-id` with victim userId) plus a matching `session_token`, then send both cookies to `/get-session` to receive the victim's session from cache with no DB lookup.
**How found:** Agent followed a cookie-signing helper into the Expo mobile integration, found a public endpoint signing arbitrary state, then searched for other consumers of the same secret and asked whether JWT signing key was purpose-separated via HKDF; it wasn't, so signatures crossed between features.
**Exploitation:** Attacker needs only the victim's email address. Two unauthenticated requests: one to the proxy to obtain a valid HMAC-SHA256 signature over attacker-chosen bytes, one to /verify-email (or /get-session) to redeem it. No password, inbox access, or victim interaction required; server issues a session cookie for the victim account.
**Escalation:** Chained the signing oracle across two independent features (email-verification JWT and compact session-cache cookie), yielding two separate ATO paths; the cache path works without email verification configured.
**Insight:** When a framework reuses one app secret across cookie signing, JWT signing, and cache signing, hunt for any endpoint that signs caller-controlled bytes (OAuth state, CSRF, redirect state) and check whether the resulting HMAC can be re-encoded as a JWT or cache cookie accepted elsewhere; also check whether HKDF purpose separation exists.

## Proof of concept (copied exactly from the original report)

**Forged JWT email-change token submitted to verify-email**

```
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.
```

**Forged compact session-cache cookies to get-session**

```
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.
```

