Wispr Flow Trial Bypass

AI-assisted reverse engineering of an Electron + Supabase desktop app. 30 seconds end-to-end, zero browser, zero captcha solver.

StackPython · Electron · Supabase · mail.tm
RuntimeWSL2 + Windows 11
Runtime~30 seconds
Reliability5/5 test runs
Reverse Engineering Electron Supabase Python mail.tm API AI-Assisted

TL;DR — I reverse-engineered Wispr Flow's desktop auth in 60 minutes, bypassed hCaptcha via their public POST /api/v1/email/signup endpoint (intended for desktop apps, no captcha_token required), parsed the Supabase access_token from email verification callbacks, and injected the resulting session into the desktop app's plaintext JSON storage. The result is a cron job that resets the 14-day trial every 2 days, fully automated.

The problem

Wispr Flow's desktop dictation app has a 14-day Pro trial. Once expired, the only way to keep using it is to either pay for a subscription, or sign up with a new email address. The signup flow has three friction points:

  1. hCaptcha on wisprflow.ai/login — the standard "I'm not a robot" checkbox
  2. Email verification — clicking a link in the confirmation email
  3. Desktop app login — opening Wispr Flow, typing email + password, waiting for sync

I wanted this automated. The naive solution would be to use 2Captcha or a similar service to solve the captcha, then drive a real browser through Playwright. That's $0.003 per run + maintenance burden + browser fingerprint detection after a few runs.

I asked a different question: does their desktop app use the same signup form?

The first break — finding the non-captcha endpoint

Wispr Flow exposes a public OpenAPI spec at https://api.wisprflow.ai/openapi.json. This is common for SaaS companies — they use the spec to generate client SDKs and to document their API for partners.

Searching for signup-related paths:

GET https://api.wisprflow.ai/openapi.json | jq '.paths | keys[]' | grep signup

/api/v1/email/signup
/api/v1/email/signup-captcha
/api/v1/signup
/api/v1/signup_raw_email

Two endpoints that take email + password. Looking at the request schemas:

SignupWithEmailRequest          SignupWithEmailCaptchaRequest
{                              {
  email: string,                 email: string,
  password: string,              password: string,
  full_name: string,             full_name: string,
  device_code?: uuid             captcha_token: string,    <- required
}                                device_code?: uuid
                               }

One requires captcha_token. The other doesn't. Both return the same response shape.

The captcha-required endpoint is what the web signup form calls. The captcha-free endpoint is what the desktop app calls (desktop apps can't solve hCaptcha interactively, so the API accepts programmatic signups).

Test:

curl -X POST https://api.wisprflow.ai/email/signup \
  -H 'Content-Type: application/json' \
  -d '{"email":"probe@web-library.net","password":"WF-test-12345!","full_name":"Test User"}'

→ HTTP 200
→ {"message":"User signed up successfully. Please check your email for verification.",
→  "user_id":"4c1bc434-0eb2-4251-8d4d-57d3545d2d06",
→  "email":"probe@web-library.net"}

Captcha bypassed without touching a captcha. This took 2 minutes once I started looking.

The second break — disposable inbox via API

Now I needed an email address to receive the verification link. The obvious choice — temp-mail.org — also has hCaptcha on its web UI. So I needed an API-first disposable inbox.

mail.tm is a free public REST API for disposable inboxes. Same approach:

curl https://api.mail.tm/domains
→ {"hydra:member":[{"id":"...","domain":"web-library.net",...}]}

curl -X POST https://api.mail.tm/accounts \
  -H 'Content-Type: application/json' \
  -d '{"address":"probe@web-library.net","password":"WF-test-12345"}'
→ HTTP 201, returns account_id

curl -X POST https://api.mail.tm/token \
  -H 'Content-Type: application/json' \
  -d '{"address":"probe@web-library.net","password":"WF-test-12345"}'
→ returns JWT for inbox polling

Then poll for incoming messages:

while true:
  GET https://api.mail.tm/messages  -H "Authorization: Bearer ***  if message.from == "no-reply@wispr.ai":
    extract verification link
    break
  sleep(8)

The verification email arrives within 8–16 seconds. Polling every 8 seconds is well within mail.tm's 8 QPS rate limit.

The third break — extracting access_token from Supabase callback

The Wispr confirmation email contains a link like:

https://auth.wisprflow.com/auth/v1/verify?token=<hex>&type=signup&redirect_to=https://api.wisprflow.com/email/login

This is the Supabase verify endpoint. It returns a 303 See Other redirect to:

https://api.wisprflow.com/email/login#access_token=eyJ...&expires_at=...&expires_in=604800&refresh_token=...&type=signup

The access_token is in the URL fragment (after #), not the path. Fragments are never sent to the server — they're handled by the browser. But the Location header contains the full URL with the fragment, which means we can read it via curl:

curl -s -D - -o /dev/null \
  "https://auth.wisprflow.com/auth/v1/verify?token=$TOKEN&type=signup&redirect_to=https://api.wisprflow.com/email/login"

→ HTTP/1.1 303 See Other
→ location: https://api.wisprflow.com/email/login#access_token=eyJ...&refresh_token=...

Parse Location → split on # → extract access_token and refresh_token.

For the user_id, do a separate POST /email/signin — it returns the canonical tokens plus user_id in the response body.

The fourth break — finding where the app stores the session

Now I had a valid Supabase access_token. The question: where does the desktop app keep its session, and can I write a new one to that location?

Electron apps installed via Squirrel typically use one of three persistence patterns:

Listing Wispr Flow's data directory:

ls "/mnt/c/Users/taras/AppData/Roaming/Wispr Flow/"

→ Cache/  Code Cache/  Crashpad/  DIPS  Local State
→ Local Storage/  Network/  GPUCache  Session Storage/
→ config.json  session.json  sentry/  logs/

Two interesting files: config.json (large JSON of preferences) and session.json (small JSON, single key).

session.json contents:

{
  "sb-dodjkfqhwrzqjwkfnthl-auth-token": "{\"access_token\":\"eyJ...\",\"refresh_token\":\"...\",\"user\":{...},...}"
}

That's supabase-js's localStorage adapter. The Supabase JS client persists sessions via a stringified JSON value under a key like sb-<project-id>-auth-token. Wispr's Electron app is using a custom storage backend that writes to this file instead of Chromium's leveldb.

The key is the Supabase project ID dodjkfqhwrzqjwkfnthl — visible in the JWT issuer and in their API domain (dodjkfqhwrzqjwkfnthl.supabase.co).

The fifth break — verifying session injection works

The hypothesis: if I write a valid session to session.json and restart the app, the Electron renderer will call supabase.auth.setSession(token, refresh), which validates the JWT against the project's public key, and the app is logged in as the new user.

Test procedure:

  1. Create new account via POST /email/signup
  2. Verify email via curl, extract access_token
  3. Construct valid session.json with that access_token
  4. Backup current session.json and config.json
  5. Write new session.json
  6. Patch 4 identity fields in config.json (prefs.user.uuid, email, firstName, lastName)
  7. Kill Wispr Flow, start it again via PowerShell
  8. Wait 8 seconds, verify Sentry scope has new user.id

Result: app starts, fully logged in as the new user. All preferences (audio devices, voice profiles, shortcuts, polish settings) intact.

5 out of 5 test runs successful. End-to-end time: ~30 seconds. Zero browser. Zero captcha solver. Zero manual intervention.

What I learned

OpenAPI specs are treasure maps

Any SaaS that ships a public OpenAPI spec exposes its entire backend surface area to anyone who reads. Endpoint variants that look like a duplication often exist because the team separated "web flow" from "desktop/mobile flow" — the desktop flow is usually simpler, because there's no captcha, no social proof, no marketing interstitia.

Electron apps are open by default

App.asar is unpackable with one npm command. The minified JS bundle contains the full app logic — protocol handlers, IPC channels, auth flows. grep "setAsDefaultProtocolClient" in the bundle finds every URL scheme the app registers, in seconds.

Plaintext JSON in %APPDATA% is everywhere

electron-store and supabase-js's localStorage adapter both default to plaintext JSON. Anything in Chromium's localStorage for an Electron app is recoverable from the leveldb files OR from any custom storage adapter the app uses. Treat your desktop app's data directory as part of your security perimeter.

30 seconds of investigation saves $0.003 forever

Paying 2Captcha to bypass hCaptcha would have cost $0.003 per run and required a browser + fingerprint management. The OpenAPI discovery + JSON injection approach took 60 minutes of one-time work and runs forever with zero ongoing cost. The cheaper path was also the more interesting one.

Why this is a bypass, not a vulnerability

If you ship a SaaS and want to harden against this kind of flow, you have three options:

  1. Bind session tokens to a device fingerprint server-side. Force re-auth when device_id changes.
  2. Phone verification on every signup, not just the first.
  3. Rate-limit account creation per device_id and per IP.

Reproduction recipe

git clone https://github.com/taras-polishchuk/wispr-flow-trial-bypass
cd wispr-flow-trial-bypass

# Edit paths in wispr_reregister.py if needed
# Then:
python3 wispr_reregister.py

Total runtime: ~30 seconds. Watch the desktop app open with the new account logged in.

Source & references