AI-assisted reverse engineering of an Electron + Supabase desktop app. 30 seconds end-to-end, zero browser, zero captcha solver.
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.
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:
wisprflow.ai/login — the standard "I'm not a robot" checkboxI 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?
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.
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 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.
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:
electron-store — JSON file in %APPDATA%/<AppName>/config.jsonlocalStorage — leveldb files in %APPDATA%/<AppName>/Local Storage/leveldb/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 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:
POST /email/signupsession.json with that access_tokensession.json and config.jsonsession.jsonconfig.json (prefs.user.uuid, email, firstName, lastName)user.idResult: 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.
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.
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.
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.
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.
POST /api/v1/email/signup is what the desktop app itself calls.If you ship a SaaS and want to harden against this kind of flow, you have three options:
device_id changes.device_id and per IP.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.