From "Log in with OAuth" to "Your Account Is Mine" – Desktop App Edition

Abstract
Just one click on a malicious link → account takeover. No phishing, no malware.
I discovered a security flaw in a popular desktop app’s OAuth flow that let me steal any user’s account just by one click. The root causes: missing state validation, long-lived tokens stored after loopback redirect, and blind trust in a remote_key parameter. In this post, I walk you through the exact attack step-by-step, why it worked so cleanly, and how vendors can actually fix it.
A few months ago, I decided to start looking for vulnerabilities in desktop applications. To clarify, when I say “finding bugs in desktop apps,“ I mean the parts where a desktop application interacts with its web backend, like authentication, data exchange, or any other integration points. The target for this write-up is a well-known company. I can't disclose its name, so throughout this post, I'll refer to the domain as redacted.com.
Setup
Because I wanted to capture a desktop application's traffic, I installed the BurpSuite certificate on my OS as a root authority. Then, I set up a system-wide proxy through BurpSuite and opened the desktop application. When I launched the application and checked the captured requests, I realized that it did not use certificate pinning. This simplified the traffic interception and meant I didn't need Frida.
Reconnaissance
Understand the application better than its developers do.
I started using the application like a regular user by clicking on every button and trying out all the features, especially the hidden or small ones tucked away in corners, while intercepting the traffic with Burp.The application required you to log in before use, offering two methods: traditional credentials or OAuth.
OAuth flows are particularly interesting to security researchers because there are many ways they can be implemented. The most interesting part is where developers customize the flow to suit their needs, as this is where bugs often appear. (The image below is a sample OAuth page from my !Safe-Blog Project)
OAuth Authorization Flow
I started logging into my account using OAuth. When you clicked on one of the OAuth options, such as “Login with Google” or “Login with Facebook,” a browser window opened with the following URL:
https://oauth2.redacted.com/auth/google?remote_key=[REMOTE_KEY]
&jwt=1&client_id=[CLIENT_ID]&lang=en&return_skip=1
The remote_key was randomly generated each time a user wanted to use OAuth, while the client ID remained the same. This URL then took you to the OAuth provider to log into your account. After you finished the OAuth process, you were redirected to the company website with a message that said, “Login completed, just close the browser.“ When you returned to the desktop app, you saw that you were logged into your account.
The first question I asked myself was: “How was the authentication transferred from the browser to the desktop application?“ I looked for deep links but didn't find any, so I checked the WebSocket section in Burp. I noticed that the desktop app initiates a WebSocket connection with the server right after generating the
remote_key. It continuously checks in a loop to see if the user is authenticated. If the user logs in through the browser, the WebSocket server sends back an access token, confirming the user is authenticated, and then the desktop app logs the user into their account.
Here's what happened in the open WebSocket connection after the authentication was successfully completed:
So based on these observations, the diagram below illustrates the OAuth flow:
The user opens the Desktop Application.
The desktop app displays a login page with two options: Login with credentials and Login with OAuth.
The user selects OAuth Login.
The desktop app launches the default browser and opens:
https://oauth2.redacted.com/auth/google?remote_key=[REMOTE_KEY]&jwt=1&client_id=[CLIENT_ID]&lang=en&return_skip=1The browser shows the OAuth provider page. The user completes the OAuth login.
After successful OAuth authentication, the browser redirects to the company website with a message: “Login completed, just close the browser.“
Meanwhile, right after starting the browser login, the Desktop App initiates a WebSocket connection to the backend server.
The Desktop App continuously checks the authentication status by sending the
remote_keyto the WebSocket Server in a loop, showing multiple cycles:Check #1 → server responds “not authenticated”
Check #2 → “not authenticated”
Check #3 → “not authenticated”
Once the browser OAuth is completed, the server detects the session and sends a WebSocket response containing an access token.
- Check #x → “User Authenticated! (Access_Token: xxxxxxxx)”
The desktop app receives the access token, marks the user as authenticated, and logs them into their account.
Finally, the user is logged into the desktop app through the OAuth flow that started from the web browser.
Attack Scenario
As a security researcher, the first thing that comes to mind is to test if the remote_key is linked to the desktop application session or an identifier.
I asked myself: “What happens if I initiate the Login with OAuth process in my desktop app, copy the generated OAuth URL (
https://oauth2.redacted.com/auth/google?remote_key=[ATTACKER_REMOTE_KEY]&jwt=1&client_id=[CLIENT_ID]&lang=en&return_skip=1) from the browser it launched, and send it to a victim? If they complete the OAuth login, will my desktop app authenticate as them?“ I decided to test this scenario.
I logged out of my account, repeated the OAuth authentication flow, and copied the newly generated URL from the browser launched by the desktop application. I then used my phone as the victim device and opened the link there. To my surprise, the desktop application authenticated me as the victim.
Accordingly, the attack scenario can be illustrated as follows:
The Attacker opens the Desktop Application.
The desktop app displays a login page with two options: Login with credentials and Login with OAuth.
The Attacker selects OAuth Login.
The desktop app launches the default browser and opens:
https://oauth2.redacted.com/auth/google?remote_key=[ATTACKER_REMOTE_KEY]&jwt=1&client_id=[CLIENT_ID]&lang=en&return_skip=1Attacker Copies the URL and send it to the victim
Victim clicks on the link and browser will open
The browser shows the OAuth provider page. The victim completes the Google OAuth login.
After successful OAuth authentication, the browser redirects to the company website with a message: “Login completed, just close the browser.“
Meanwhile on the Attacker’s Side, right after starting the browser login, the Desktop App initiates a WebSocket connection to the backend server.
The Desktop App continuously checks the authentication status by sending the
remote_keyto the WebSocket Server in a loop, showing multiple cycles:Check #1 → server responds “not authenticated”
Check #2 → “not authenticated”
Check #3 → “not authenticated”
Once the victim’s OAuth flow is completed, the server detects the session and sends a WebSocket response containing victim’s access token.
- Check #x → “User Authenticated! (Access_Token: xxxxxxxx)”
On the attacker side The desktop app receives the access token, marks the attacker as authenticated, and logs them into victim account.
As demonstrated, there were no mechanisms in place to verify whether the user authenticating on the desktop application was the same individual who was logged into the web session.
Mitigation
The root cause of this vulnerability lies in the absence of state validation between the OAuth initiator and the session completer. The remote_key parameter acts as a session identifier but lacks any binding to the device or user that generated it, allowing an attacker to hijack the authentication flow.
To address this vulnerability, the following mitigations should be implemented:
1. Implement OAuth State Parameter
The application should generate a cryptographically secure, random state parameter that is:
Bound to the desktop application session
Validated upon OAuth callback
Single-use and expires after a short time window (e.g., 5 minutes)
This ensures that only the entity that initiated the OAuth flow can complete it.
2. Bind Remote Key to Device/Session
The remote_key should be cryptographically tied to the desktop application's session or device identifier. When the WebSocket connection is established, the server should verify that:
The
remote_keywas generated by the same device making the authentication requestThe desktop app instance matches the one that initiated the flow
3. Add User Confirmation Step
After the OAuth provider redirects back to the company website, it should display clear information about which device is requesting authentication:
Device name/type
Approximate location
Timestamp of the request
Require the user to explicitly confirm: “Are you trying to log in to [Device Name]?“ before sending the access token through the WebSocket.
4. Implement Remote Key Expiration
The remote_key should have a short lifespan (e.g., 2-3 minutes). If the OAuth flow is not completed within this timeframe, the key should be invalidated on the server side, and the desktop application should display a timeout error. This mechanism was implemented in this case, but this alone doesn't secure OAuth.
5. Rate Limiting and Anomaly Detection
Implement monitoring to detect suspicious patterns:
Multiple failed authentication attempts with different
remote_keyvaluesAuthentication requests from geographically distant locations within short time periods
Unusual WebSocket connection patterns
By implementing these mitigations, the application can ensure that the OAuth authentication flow remains secure and that only the legitimate user who initiated the login process can complete it on their desktop application. Please note that mitigation steps 4 and 5 only make the attack more complex and will not secure OAuth on their own.
Final Words
Don't hesitate to test cross-platform applications. Many security researchers avoid desktop or mobile apps because they seem complex, but take it easy and grow your mindset. You're a hacker! For a real hacker, there are no limitations. Approach cross-platform applications with curiosity and a willingness to investigate deeper layers. As you saw in this write-up, you should expand the attack surface as much as possible. Every application is like an iceberg; what you see is not all there is, so explore what's beneath the surface. After I reported this issue, the developer fixed it quickly. Once the initial report was marked as resolved, I decided to dig deeper into their new implementation and successfully bypassed the patch. In my next write-up, I’ll explain exactly how I did it and what went wrong with their fix.
I hope you enjoyed this write-up and found it helpful. See you in the next one!٫
